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..564bfd4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ 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": "..." }, } ``` diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 986f50a..94677dd 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -21,6 +21,7 @@ services: - ./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: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/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 index a86337d..f206224 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -1,49 +1,18 @@ +# !/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Client.""" +from netbox_diode_plugin.diode.clients import get_api_client def create_client(request, client_name, scope): - ret = { - "client_name": "client_name1", - "client_id": "client_id", - "client_secret": "client_secret", - "scope": "scope", - "created_at": "2025-03-14T15:16:17Z" - } - return ret - + return get_api_client().create_client(client_name, scope) def delete_client(request, client_id): - pass - - -def get_client(request, client_id): - ret = { - "client_name": "client_name1", - "client_id": "client_id", - "client_secret": "client_secret", - "scope": "scope", - "created_at": "2025-03-14T15:16:17Z" - } - return ret - + return get_api_client().delete_client(client_id) def list_clients(request): - ret = { - "data": [ - { - "client_name": "My Agent 1", - "client_id": "my-agent-1-a038dfef", - "scope": "diode:ingest", - "created_at": "2025-03-14T15:16:17Z" - }, - { - "client_name": "US East 12", - "client_id": "us-east-12-f00fa3cd", - "scope": "diode:ingest", - "created_at": "2025-04-15T10:11:00Z" - } - ], - "next_page_token": "3", - } - return ret["data"] - + response = get_api_client().list_clients() + return response["data"] +def get_client(request, client_id): + return get_api_client().get_client(client_id) \ No newline at end of file diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py new file mode 100644 index 0000000..2efebd6 --- /dev/null +++ b/netbox_diode_plugin/diode/clients.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs Inc +"""Diode NetBox Plugin - Diode - Auth.""" + +from dataclasses import dataclass +import datetime +import json +import logging +import requests +import threading +from urllib.parse import urlencode +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") + + +_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() + 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, data=data, headers=headers) + if response.status_code != 200: + 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.""" + 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.""" + 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 == 401 or response.status_code == 403: + raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) + if response.status_code != 200: + raise ClientAPIError(f"Failed to delete 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 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 == 401 or response.status_code == 403: + raise ClientAPIError("Failed to get clients", response.status_code) + 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/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."""