diff --git a/.gitignore b/.gitignore index b5821bd..53cce25 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ dist/ # Docker docker/coverage !docker/netbox/env +docker/oauth2/secrets/* +!docker/oauth2/secrets/.gitkeep diff --git a/README.md b/README.md index 96f93b5..0908329 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,18 @@ PLUGINS_CONFIG = { # Username associated with changes applied via plugin "diode_username": "diode", + + # netbox-to-diode client_secret created during diode bootstrap. + "netbox_to_diode_client_secret": "..." }, } ``` +If you are running diode locally via the quickstart, the `netbox-to-diode` client_secret may be found in `/path/to/diode/oauth2/client/client-credentials.json`. eg: +``` +echo $(jq -r '.[] | select(.client_id == "netbox-to-diode") | .client_secret' /path/to/diode/oauth2/client/client-credentials.json) +``` + Note: Once you customise usernames with PLUGINS_CONFIG during first installation, you should not change or remove them later on. Doing so will cause the plugin to stop working properly. diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 88b6702..94677dd 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -20,7 +20,8 @@ services: volumes: - ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro - ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro - - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:ro + - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw + - ./oauth2/secrets:/run/secrets:z,ro - ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro - ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro - ./coverage:/opt/netbox/netbox/coverage:z,rw diff --git a/docker/netbox/env/netbox.env b/docker/netbox/env/netbox.env index 2215733..4920b18 100644 --- a/docker/netbox/env/netbox.env +++ b/docker/netbox/env/netbox.env @@ -36,6 +36,6 @@ SUPERUSER_EMAIL= SUPERUSER_NAME=admin SUPERUSER_PASSWORD=admin WEBHOOKS_ENABLED=true -RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=false +RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=true BASE_PATH=netbox/ DEBUG=False \ No newline at end of file diff --git a/docker/oauth2/secrets/.gitkeep b/docker/oauth2/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netbox_diode_plugin/__init__.py b/netbox_diode_plugin/__init__.py index 16f718d..3b3b48f 100644 --- a/netbox_diode_plugin/__init__.py +++ b/netbox_diode_plugin/__init__.py @@ -22,6 +22,15 @@ class NetBoxDiodePluginConfig(PluginConfig): # Default username associated with changes applied via plugin "diode_username": "diode", + + # client_id and client_secret for communication with Diode server. + # By default, the secret is read from a file /run/secrets/netbox_to_diode + # but may be specified directly as a string in netbox_to_diode_client_secret + "netbox_to_diode_client_id": "netbox-to-diode", + "netbox_to_diode_client_secret": None, + "secrets_path": "/run/secrets/", + "netbox_to_diode_client_secret_name": "netbox_to_diode", + "diode_max_auth_retries": 3, } diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py new file mode 100644 index 0000000..5d9e785 --- /dev/null +++ b/netbox_diode_plugin/client.py @@ -0,0 +1,36 @@ +# !/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Client.""" + +import logging + +from netbox_diode_plugin.diode.clients import get_api_client + +logger = logging.getLogger("netbox.diode_data") + + +def create_client(request, client_name: str, scope: str): + """Create client.""" + logger.info(f"Creating client {client_name} with scope {scope}") + return get_api_client().create_client(client_name, scope) + + +def delete_client(request, client_id: str): + """Delete client.""" + sanitized_client_id = client_id.replace("\n", "").replace("\r", "") + logger.info(f"Deleting client {sanitized_client_id}") + return get_api_client().delete_client(client_id) + + +def list_clients(request): + """List clients.""" + logger.info("Listing clients") + response = get_api_client().list_clients() + return response["data"] + + +def get_client(request, client_id: str): + """Get client.""" + sanitized_client_id = client_id.replace("\n", "").replace("\r", "") + logger.info(f"Getting client {sanitized_client_id}") + return get_api_client().get_client(client_id) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py new file mode 100644 index 0000000..32a3670 --- /dev/null +++ b/netbox_diode_plugin/diode/clients.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs Inc +"""Diode NetBox Plugin - Diode - Auth.""" + +import datetime +import json +import logging +import re +import threading +from dataclasses import dataclass +from urllib.parse import urlencode + +import requests + +from netbox_diode_plugin.plugin_config import ( + get_diode_auth_base_url, + get_diode_credentials, + get_diode_max_auth_retries, +) + +SCOPE_DIODE_READ = "diode:read" +SCOPE_DIODE_WRITE = "diode:write" + +logger = logging.getLogger("netbox.diode_data") + +valid_client_id_re = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") + +_client = None +_client_lock = threading.Lock() +def get_api_client(): + """Get the client API client.""" + global _client + global _client_lock + + with _client_lock: + if _client is None: + client_id, client_secret = get_diode_credentials() + if not client_id: + raise ClientAPIError( + "Please update the plugin configuration to access this feature.\nMissing netbox to diode client id.", 500) + if not client_secret: + raise ClientAPIError( + "Please update the plugin configuration to access this feature.\nMissing netbox to diode client secret.", 500) + max_auth_retries = get_diode_max_auth_retries() + _client = ClientAPI( + base_url=get_diode_auth_base_url(), + client_id=client_id, + client_secret=client_secret, + max_auth_retries=max_auth_retries, + ) + return _client + + +class ClientAPIError(Exception): + """Client API Error.""" + + def __init__(self, message: str, status_code: int = 500): + """Initialize the ClientAPIError.""" + self.message = message + self.status_code = status_code + super().__init__(self.message) + + def is_auth_error(self) -> bool: + """Check if the error is an authentication error.""" + return self.status_code == 401 or self.status_code == 403 + +class ClientAPI: + """Manages Diode Clients.""" + + def __init__(self, base_url: str, client_id: str, client_secret: str, max_auth_retries: int = 2): + """Initialize the ClientAPI.""" + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + + self._max_auth_retries = max_auth_retries + self._client_auth_token = None + self._client_auth_token_lock = threading.Lock() + + def create_client(self, name: str, scope: str) -> dict: + """Create a client.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + "/clients" + headers = {"Authorization": f"Bearer {token}"} + data = { + "client_name": name, + "scope": scope, + } + response = requests.post(url, json=data, headers=headers) + if response.status_code != 201: + raise ClientAPIError("Failed to create client", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying create_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError("Failed to create client: unexpected state", 500) + + def get_client(self, client_id: str) -> dict: + """Get a client.""" + if not valid_client_id_re.match(client_id): + raise ValueError(f"Invalid client ID: {client_id}") + + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + f"/clients/{client_id}" + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(url, headers=headers) + if response.status_code == 401 or response.status_code == 403: + raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) + if response.status_code != 200: + raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError(f"Failed to get client {client_id}: unexpected state") + + def delete_client(self, client_id: str) -> None: + """Delete a client.""" + if not valid_client_id_re.match(client_id): + raise ValueError(f"Invalid client ID: {client_id}") + + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + f"/clients/{client_id}" + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(url, headers=headers) + if response.status_code != 204: + raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) + return + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError(f"Failed to delete client {client_id}: unexpected state") + + + def list_clients(self, page_token: str | None = None, page_size: int | None = None) -> list[dict]: + """List all clients.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + "/clients" + headers = {"Authorization": f"Bearer {token}"} + params = {} + if page_token: + params["page_token"] = page_token + if page_size: + params["page_size"] = page_size + response = requests.get(url, headers=headers, params=params) + if response.status_code != 200: + raise ClientAPIError("Failed to get clients", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying list_clients due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError("Failed to list clients: unexpected state") + + + def _get_token(self) -> str: + """Get a token for the Diode Auth Service.""" + with self._client_auth_token_lock: + if self._client_auth_token: + return self._client_auth_token + self._client_auth_token = self._authenticate() + return self._client_auth_token + + def _mark_client_auth_token_invalid(self, token: str): + """Mark a client auth token as invalid.""" + with self._client_auth_token_lock: + self._client_auth_token = None + + def _authenticate(self) -> str: + """Get a new access token for the Diode Auth Service.""" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = urlencode( + { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": f"{SCOPE_DIODE_READ} {SCOPE_DIODE_WRITE}", + } + ) + url = self.base_url + "/token" + try: + response = requests.post(url, data=data, headers=headers) + except Exception as e: + raise ClientAPIError(f"Failed to obtain access token: {e}", 401) from e + if response.status_code != 200: + raise ClientAPIError(f"Failed to obtain access token: {response.reason}", 401) + + try: + token_info = response.json() + except Exception as e: + raise ClientAPIError(f"Failed to parse access token response: {e}", 401) from e + + access_token = token_info.get("access_token") + if not access_token: + raise ClientAPIError(f"Failed to obtain access token for client {self._client_id}", 401) + + return access_token + diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 5bec310..05de3bd 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -1,6 +1,8 @@ # !/usr/bin/env python # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Forms.""" +from django import forms +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from netbox.plugins import get_plugin_config from utilities.forms.rendering import FieldSet @@ -9,6 +11,7 @@ __all__ = ( "SettingsForm", + "ClientCredentialForm", ) @@ -40,3 +43,14 @@ def __init__(self, *args, **kwargs): self.fields["diode_target"].help_text = ( "This field is not allowed to be modified." ) + + +class ClientCredentialForm(forms.Form): + """Form for adding client credentials.""" + + client_name = forms.CharField( + label=_("Client Name"), + required=True, + help_text=_("Enter a name for the client credential that will be created for authentication to the Diode ingestion service."), + widget=forms.TextInput(attrs={"class": "form-control"}), + ) diff --git a/netbox_diode_plugin/models.py b/netbox_diode_plugin/models.py index 9079f9e..ed9e6c7 100644 --- a/netbox_diode_plugin/models.py +++ b/netbox_diode_plugin/models.py @@ -40,4 +40,18 @@ def get_absolute_url(self): return reverse("plugins:netbox_diode_plugin:settings") +class ClientCredentials(models.Model): + """Dummy model to allow for permissions, saved filters, etc..""" + + class Meta: + """Meta class.""" + + managed = False + + default_permissions = () + + permissions = ( + ("view_clientcredentials", "Can view Client Credentials"), + ("add_clientcredentials", "Can perform actions on Client Credentials"), + ) diff --git a/netbox_diode_plugin/navigation.py b/netbox_diode_plugin/navigation.py index 4fb18ef..cdc64ce 100644 --- a/netbox_diode_plugin/navigation.py +++ b/netbox_diode_plugin/navigation.py @@ -2,24 +2,26 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Navigation.""" +from django.utils.translation import gettext as _ from netbox.plugins import PluginMenu, PluginMenuItem -settings = { - "link": "plugins:netbox_diode_plugin:settings", - "link_text": "Settings", - "staff_only": True, -} - +_diode_menu_items = ( + PluginMenuItem( + link="plugins:netbox_diode_plugin:settings", + link_text=_("Settings"), + staff_only= True, + ), + PluginMenuItem( + link="plugins:netbox_diode_plugin:client_credential_list", + link_text=_("Client Credentials"), + staff_only= True, + ), +) menu = PluginMenu( label="Diode", groups=( - ( - "Diode", - ( - PluginMenuItem(**settings), - ), - ), + (_("Diode"), _diode_menu_items), ), icon_class="mdi mdi-upload", ) diff --git a/netbox_diode_plugin/plugin_config.py b/netbox_diode_plugin/plugin_config.py index 60dbcad..3c15c0e 100644 --- a/netbox_diode_plugin/plugin_config.py +++ b/netbox_diode_plugin/plugin_config.py @@ -2,6 +2,8 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Plugin Settings.""" +import logging +import os from urllib.parse import urlparse from django.contrib.auth import get_user_model @@ -14,6 +16,7 @@ User = get_user_model() +logger = logging.getLogger("netbox.diode_data") def _parse_diode_target(target: str) -> tuple[str, str, bool]: """Parse the target into authority, path and tls_verify.""" @@ -31,6 +34,11 @@ def _parse_diode_target(target: str) -> tuple[str, str, bool]: def get_diode_auth_introspect_url(): """Returns the Diode Auth introspect URL.""" + diode_auth_base_url = get_diode_auth_base_url() + return f"{diode_auth_base_url}/introspect" + +def get_diode_auth_base_url(): + """Returns the Diode Auth service base URL.""" diode_target = get_plugin_config("netbox_diode_plugin", "diode_target") diode_target_override = get_plugin_config( "netbox_diode_plugin", "diode_target_override" @@ -42,8 +50,34 @@ def get_diode_auth_introspect_url(): scheme = "https" if tls_verify else "http" path = path.rstrip("/") - return f"{scheme}://{authority}{path}/auth/introspect" + return f"{scheme}://{authority}{path}/auth" + +def get_diode_credentials(): + """Returns the Diode credentials.""" + client_id = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_id") + secrets_path = get_plugin_config("netbox_diode_plugin", "secrets_path") + secret_name = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret_name") + client_secret = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret") + + if not client_secret: + secret_file = os.path.join(secrets_path, secret_name) + client_secret = _read_secret(secret_file, client_secret) + return client_id, client_secret + +def get_diode_max_auth_retries(): + """Returns the Diode max auth retries.""" + return get_plugin_config("netbox_diode_plugin", "diode_max_auth_retries") + +# Read secret from file +def _read_secret(secret_file: str, default: str | None = None) -> str | None: + try: + f = open(secret_file, encoding='utf-8') + except OSError: + return default + else: + with f: + return f.readline().strip() def get_diode_user(): """Returns the Diode user.""" diff --git a/netbox_diode_plugin/tables.py b/netbox_diode_plugin/tables.py new file mode 100644 index 0000000..8e69d6a --- /dev/null +++ b/netbox_diode_plugin/tables.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# Copyright 2024 NetBox Labs Inc +"""Diode NetBox Plugin - Tables.""" +import logging + +import django_tables2 as tables +from django.urls import reverse +from django.utils.dateparse import parse_datetime +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from netbox.tables import BaseTable, columns + + +class ClientCredentialsTable(BaseTable): + """Client credentials table.""" + + label = tables.Column( + verbose_name=_("Name"), + accessor="client_name", + orderable=False, + ) + client_id = tables.Column( + verbose_name=_("Client ID"), + accessor="client_id", + orderable=False, + ) + created_at = columns.DateTimeColumn( + verbose_name=_("Created"), + accessor="created_at", + orderable=False, + ) + client_secret = tables.Column( + verbose_name=_("Client Secret"), + empty_values=(), + orderable=False, + ) + actions = tables.Column( + verbose_name=_(""), + orderable=False, + empty_values=(), + attrs={ + "td": { + "class": "text-end", + } + }, + ) + + exempt_columns = ("actions") + embedded = False + + class Meta: + """Meta class.""" + + attrs = { + "class": "table table-hover object-list", + "td": {"class": "align-middle"}, + } + fields = None + default_columns = ( + "label", + "client_id", + "created_at", + "client_secret", + "actions", + ) + + empty_text = _("No Client Credentials to display") + footer = False + + def render_client_secret(self, value): + """Render client secret.""" + return "*****" + + def render_created_at(self, value): + """Render created at.""" + if value: + return parse_datetime(value) + return "-" + + def render_actions(self, record): + """Render actions.""" + delete_url = reverse( + "plugins:netbox_diode_plugin:client_credential_delete", + kwargs={"client_credential_id": record["client_id"]}, + ) + + buttons = f""" + + + + """ # noqa: E501 + + return mark_safe(buttons) diff --git a/netbox_diode_plugin/templates/diode/client_credential_add.html b/netbox_diode_plugin/templates/diode/client_credential_add.html new file mode 100644 index 0000000..30459f0 --- /dev/null +++ b/netbox_diode_plugin/templates/diode/client_credential_add.html @@ -0,0 +1,18 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Add Client Credential" %}{% endblock %} + +{% block form %} + {% render_form form %} +{% endblock %} + +{% block buttons %} +{% trans "Cancel" %} +