Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ dist/
# Docker
docker/coverage
!docker/netbox/env
docker/oauth2/secrets/*
!docker/oauth2/secrets/.gitkeep
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "..."
},
}
```
Expand Down
1 change: 1 addition & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file added docker/oauth2/secrets/.gitkeep
Empty file.
9 changes: 9 additions & 0 deletions netbox_diode_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
51 changes: 10 additions & 41 deletions netbox_diode_plugin/client.py
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 5 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (I001)

netbox_diode_plugin/client.py:5:1: I001 Import block is un-sorted or un-formatted

def create_client(request, client_name, scope):

Check failure on line 7 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (D103)

netbox_diode_plugin/client.py:7:5: D103 Missing docstring in public function
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):

Check failure on line 10 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (D103)

netbox_diode_plugin/client.py:10:5: D103 Missing docstring in public function
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):

Check failure on line 13 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (D103)

netbox_diode_plugin/client.py:13:5: D103 Missing docstring in public function
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):

Check failure on line 17 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (D103)

netbox_diode_plugin/client.py:17:5: D103 Missing docstring in public function
return get_api_client().get_client(client_id)

Check failure on line 18 in netbox_diode_plugin/client.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (W292)

netbox_diode_plugin/client.py:18:50: W292 No newline at end of file
209 changes: 209 additions & 0 deletions netbox_diode_plugin/diode/clients.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some basic tests for ClientAPI, please?

Original file line number Diff line number Diff line change
@@ -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,
)

Check failure on line 16 in netbox_diode_plugin/diode/clients.py

View workflow job for this annotation

GitHub Actions / tests (3.10)

Ruff (I001)

netbox_diode_plugin/diode/clients.py:5:1: I001 Import block is un-sorted or un-formatted

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

36 changes: 35 additions & 1 deletion netbox_diode_plugin/plugin_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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"
Expand All @@ -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."""
Expand Down
Loading