├── tests ├── __init__.py ├── kea.py ├── constants.py ├── docker │ ├── plugins.py │ ├── htpasswd │ ├── Dockerfile-kea │ ├── Dockerfile │ ├── docker-compose.override.yml │ ├── nginx.conf │ ├── docker-compose.yml │ └── kea_configs │ │ ├── kea-ctrl-agent.conf │ │ ├── kea-dhcp6.conf │ │ └── kea-dhcp4.conf ├── test_setup.sh ├── conftest.py ├── test_netbox_kea_api_server.py └── test_ui.py ├── netbox_kea ├── api │ ├── __init__.py │ ├── urls.py │ ├── views.py │ └── serializers.py ├── migrations │ ├── __init__.py │ ├── 0002_alter_server_options_server_dhcp4_server_dhcp6.py │ └── 0001_initial.py ├── filtersets.py ├── __init__.py ├── constants.py ├── templates │ └── netbox_kea │ │ ├── exception_htmx.html │ │ ├── server_status.html │ │ ├── server_dhcp_leases.html │ │ ├── server_dhcp_subnets.html │ │ ├── server.html │ │ ├── inc │ │ └── server_dhcp_leases_paginator.html │ │ └── server_dhcp_leases_htmx.html ├── navigation.py ├── graphql.py ├── urls.py ├── kea.py ├── utilities.py ├── models.py ├── forms.py ├── tables.py └── views.py ├── images └── leases.png ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── pyproject.toml ├── README.md ├── .gitignore └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_kea/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_kea/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/kea.py: -------------------------------------------------------------------------------- 1 | ../netbox_kea/kea.py -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | ../netbox_kea/constants.py -------------------------------------------------------------------------------- /tests/docker/plugins.py: -------------------------------------------------------------------------------- 1 | PLUGINS = ["netbox_kea"] 2 | -------------------------------------------------------------------------------- /images/leases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devon-mar/netbox-kea/HEAD/images/leases.png -------------------------------------------------------------------------------- /tests/docker/htpasswd: -------------------------------------------------------------------------------- 1 | kea:$2y$10$T3hrt.yG7V0QZW36.MRudu1tLcmlz.iydSRGhWcsjq0DBGaYzqexa 2 | -------------------------------------------------------------------------------- /tests/docker/Dockerfile-kea: -------------------------------------------------------------------------------- 1 | FROM alpine:3.19 2 | RUN apk add kea-ctrl-agent kea-dhcp4 kea-dhcp6 kea-hook-lease-cmds kea-hook-stat-cmds 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | extend-ignore = E203 4 | exclude= 5 | .git, 6 | netbox_kea/migrations/, 7 | .venv, 8 | -------------------------------------------------------------------------------- /netbox_kea/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | 3 | from . import views 4 | 5 | app_name = "netbox_kea" 6 | 7 | router = NetBoxRouter() 8 | router.register("servers", views.ServerViewSet) 9 | 10 | urlpatterns = router.urls 11 | -------------------------------------------------------------------------------- /tests/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG FROM 2 | FROM ${FROM} 3 | 4 | ARG WHL_FILE 5 | COPY ${WHL_FILE} /opt/netbox/dist/ 6 | RUN [ -f "/usr/local/bin/uv" ] && /usr/local/bin/uv pip install /opt/netbox/dist/${WHL_FILE} \ 7 | || /opt/netbox/venv/bin/pip install /opt/netbox/dist/${WHL_FILE} 8 | -------------------------------------------------------------------------------- /netbox_kea/filtersets.py: -------------------------------------------------------------------------------- 1 | from netbox.filtersets import NetBoxModelFilterSet 2 | 3 | from .models import Server 4 | 5 | 6 | class ServerFilterSet(NetBoxModelFilterSet): 7 | class Meta: 8 | model = Server 9 | fields = ("id", "name", "server_url", "dhcp4", "dhcp6") 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: uv 5 | directory: / 6 | versioning-strategy: increase-if-necessary 7 | schedule: 8 | interval: daily 9 | - package-ecosystem: github-actions 10 | directory: / 11 | schedule: 12 | interval: daily 13 | -------------------------------------------------------------------------------- /netbox_kea/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginConfig 2 | 3 | 4 | class NetBoxKeaConfig(PluginConfig): 5 | name = "netbox_kea" 6 | verbose_name = "Kea" 7 | description = "Kea integration for NetBox" 8 | version = "1.0.3" 9 | base_url = "kea" 10 | default_settings = {"kea_timeout": 30} 11 | 12 | 13 | config = NetBoxKeaConfig 14 | -------------------------------------------------------------------------------- /netbox_kea/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | 3 | from .. import filtersets, models 4 | from .serializers import ServerSerializer 5 | 6 | 7 | class ServerViewSet(NetBoxModelViewSet): 8 | queryset = models.Server.objects.prefetch_related("tags") 9 | filterset_class = filtersets.ServerFilterSet 10 | serializer_class = ServerSerializer 11 | -------------------------------------------------------------------------------- /netbox_kea/constants.py: -------------------------------------------------------------------------------- 1 | BY_IP = "ip" 2 | BY_HOSTNAME = "hostname" 3 | BY_DUID = "duid" 4 | BY_SUBNET = "subnet" 5 | BY_SUBNET_ID = "subnet_id" 6 | BY_HW_ADDRESS = "hw" 7 | BY_CLIENT_ID = "client_id" 8 | 9 | HEX_STRING_REGEX = r"^([0-9A-Fa-f]{2}[:-]?)*([0-9A-Fa-f]{2})$" 10 | 11 | # kea/src/lib/dhcp 12 | # RFC8415 section 11.1 13 | DUID_MAX_OCTETS = 128 14 | DUID_MIN_OCTETS = 1 15 | CLIENT_ID_MAX_OCTETS = DUID_MAX_OCTETS 16 | CLIENT_ID_MIN_OCTETS = 2 17 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/exception_htmx.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
Server Error
6 |
7 | {{ type_ }}: {{ exception }} 8 |
9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | "on": 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | pypi-publish: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | # https://docs.pypi.org/trusted-publishers/ 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v6 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v7 20 | with: 21 | enable-cache: true 22 | 23 | - run: uv build 24 | 25 | - run: uv publish 26 | -------------------------------------------------------------------------------- /netbox_kea/navigation.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginMenuButton, PluginMenuItem 2 | 3 | menu_items = ( 4 | PluginMenuItem( 5 | link="plugins:netbox_kea:server_list", 6 | link_text="Servers", 7 | permissions=["netbox_kea.view_server"], 8 | buttons=( 9 | PluginMenuButton( 10 | link="plugins:netbox_kea:server_add", 11 | title="Add", 12 | icon_class="mdi mdi-plus-thick", 13 | permissions=["netbox_kea.add_server"], 14 | ), 15 | ), 16 | ), 17 | ) 18 | -------------------------------------------------------------------------------- /netbox_kea/graphql.py: -------------------------------------------------------------------------------- 1 | import strawberry 2 | import strawberry_django 3 | from netbox.graphql.types import NetBoxObjectType 4 | 5 | from . import models 6 | 7 | 8 | @strawberry_django.type( 9 | models.Server, 10 | fields="__all__", 11 | ) 12 | class ServerType(NetBoxObjectType): 13 | pass 14 | 15 | 16 | @strawberry.type 17 | class Query: 18 | @strawberry.field 19 | def server(self, id: int) -> ServerType: 20 | return models.Server.objects.get(pk=id) 21 | 22 | server_list: list[ServerType] = strawberry_django.field() 23 | 24 | 25 | schema = [Query] 26 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/server_status.html: -------------------------------------------------------------------------------- 1 | {% extends "generic/object.html" %} 2 | 3 | {% block content %} 4 | {% for name, service_data in statuses.items %} 5 |
6 |
{{ name }}
7 |
8 | 9 | {% for k,v in service_data.items %} 10 | 11 | 12 | 13 | 14 | {% endfor %} 15 |
{{ k }}{% if k == "Version" %}{{ v }}{% else %}{{ v }}{% endif %}
16 |
17 |
18 | {% endfor %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/server_dhcp_leases.html: -------------------------------------------------------------------------------- 1 | {% extends "generic/object.html" %} 2 | {% load helpers %} 3 | 4 | {% block head %} 5 | {{ block.super }} 6 | 20 | {% endblock %} 21 | 22 | {% block content %}{% include "netbox_kea/server_dhcp_leases_htmx.html" with is_embedded=True %}{% endblock %} 23 | {% block modals %} 24 | {{ block.super }} 25 | {% table_config_form table table_name="LeasesTable" %} 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /netbox_kea/migrations/0002_alter_server_options_server_dhcp4_server_dhcp6.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.8 on 2023-05-15 04:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_kea', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='server', 15 | options={'ordering': ('name',)}, 16 | ), 17 | migrations.AddField( 18 | model_name='server', 19 | name='dhcp4', 20 | field=models.BooleanField(default=True), 21 | ), 22 | migrations.AddField( 23 | model_name='server', 24 | name='dhcp6', 25 | field=models.BooleanField(default=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/test_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | echo "Generating certs" 6 | mkdir ./tests/docker/certs/ 7 | openssl req -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 -keyout ./tests/docker/certs/netbox.key -out ./tests/docker/certs/netbox.crt -addext "subjectAltName=DNS:netbox" -subj "/CN=netbox" 8 | openssl req -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 -keyout ./tests/docker/certs/nginx.key -out ./tests/docker/certs/nginx.crt -addext "subjectAltName=DNS:nginx" -subj "/CN=nginx" 9 | chmod -R 0777 ./tests/docker/certs/ 10 | 11 | echo "Copying whl" 12 | WHL_FILE=$(ls ./dist/ | grep .whl) 13 | cp "./dist/$WHL_FILE" ./tests/docker/ 14 | 15 | echo "Running docker compose up" 16 | cd ./tests/docker/ 17 | docker compose build --build-arg "FROM=netboxcommunity/netbox:$NETBOX_CONTAINER_TAG" --build-arg "WHL_FILE=$WHL_FILE" 18 | docker compose up -d 19 | -------------------------------------------------------------------------------- /netbox_kea/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from utilities.urls import get_model_urls 3 | 4 | from . import views 5 | 6 | urlpatterns = ( 7 | path("servers/", views.ServerListView.as_view(), name="server_list"), 8 | path("servers/add/", views.ServerEditView.as_view(), name="server_add"), 9 | path( 10 | "servers/delete/", 11 | views.ServerBulkDeleteView.as_view(), 12 | name="server_bulk_delete", 13 | ), 14 | path( 15 | "servers//leases6/delete/", 16 | views.ServerLeases6DeleteView.as_view(), 17 | name="server_leases6_delete", 18 | ), 19 | path( 20 | "servers//leases4/delete/", 21 | views.ServerLeases4DeleteView.as_view(), 22 | name="server_leases4_delete", 23 | ), 24 | path("servers//", include(get_model_urls("netbox_kea", "server"))), 25 | ) 26 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/server_dhcp_subnets.html: -------------------------------------------------------------------------------- 1 | {% extends "generic/object_children.html" %} 2 | 3 | {% block head %} 4 | {{ block.super }} 5 | 13 | {% endblock %} 14 | 15 | {% block bulk_controls %} 16 | 25 | {% endblock bulk_controls %} 26 | -------------------------------------------------------------------------------- /tests/docker/docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | kea-dhcp6: &kea 4 | build: 5 | context: . 6 | dockerfile: Dockerfile-kea 7 | command: /usr/sbin/kea-dhcp6 -c /config/kea-dhcp6.conf 8 | volumes: 9 | - ./kea_configs/:/config/:ro 10 | - kea-run:/run/kea/ 11 | kea-dhcp4: 12 | <<: *kea 13 | command: /usr/sbin/kea-dhcp4 -c /config/kea-dhcp4.conf 14 | kea-ctrl-agent: 15 | <<: *kea 16 | command: /usr/sbin/kea-ctrl-agent -c /config/kea-ctrl-agent.conf 17 | depends_on: 18 | - kea-dhcp4 19 | - kea-dhcp6 20 | ports: 21 | - 8001:8000 22 | nginx: 23 | image: nginx:alpine-slim 24 | depends_on: 25 | - kea-ctrl-agent 26 | volumes: 27 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 28 | - ./htpasswd:/etc/nginx/htpasswd:ro 29 | - ./certs:/etc/nginx/certs/:ro 30 | volumes: 31 | kea-run: 32 | driver: local 33 | -------------------------------------------------------------------------------- /netbox_kea/api/serializers.py: -------------------------------------------------------------------------------- 1 | from netbox.api.serializers import NetBoxModelSerializer 2 | from rest_framework import serializers 3 | 4 | from ..models import Server 5 | 6 | 7 | class ServerSerializer(NetBoxModelSerializer): 8 | url = serializers.HyperlinkedIdentityField( 9 | view_name="plugins-api:netbox_kea-api:server-detail" 10 | ) 11 | 12 | class Meta: 13 | model = Server 14 | fields = ( 15 | "id", 16 | "name", 17 | "server_url", 18 | "username", 19 | "password", 20 | "ssl_verify", 21 | "client_cert_path", 22 | "client_key_path", 23 | "ca_file_path", 24 | "dhcp6", 25 | "dhcp4", 26 | "url", 27 | "display", 28 | "tags", 29 | "last_updated", 30 | ) 31 | brief_fields = ("id", "url", "name", "server_url") 32 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/server.html: -------------------------------------------------------------------------------- 1 | {% extends "generic/object.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
Server
8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
Name{{ object.name }}
Server URL{{ object.server_url }}
DHCPv6{% checkmark object.dhcp6 %}
DHCPv4{% checkmark object.dhcp4 %}
27 |
28 |
29 |
30 |
31 | {% include "inc/panels/tags.html" %} 32 |
33 |
34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "netbox-kea" 3 | version = "1.0.3" 4 | description = "" 5 | readme = "README.md" 6 | # NetBox 4.10 requires Python 3.10 7 | requires-python = ">=3.10" 8 | authors = [ 9 | {name = "Devon Mar", email = "devon-mar@users.noreply.github.com"}, 10 | ] 11 | dependencies = [ 12 | "requests>=2.0.0,<3.0.0", 13 | "netaddr>=0.8,<2.0.0", 14 | ] 15 | 16 | [dependency-groups] 17 | dev = [ 18 | "types-requests>=2.0.0,<3.0.0", 19 | "mypy>=1.14.0,<1.15.0", 20 | "pytest>=8.0.0,<9.0.0", 21 | "pytest-playwright>=0.6.0,<0.7.0", 22 | "pynetbox>=7.3.0,<7.4.0", 23 | "django-stubs[compatible-mypy]>=5.0.0,<6.0.0", 24 | # Doesn't work with ruff-action 25 | # "ruff>=0.8.0,<0.9.0", 26 | "ruff>=0.8.0", 27 | ] 28 | 29 | [build-system] 30 | requires = ["hatchling"] 31 | build-backend = "hatchling.build" 32 | 33 | [tool.hatch.build.targets.sdist] 34 | include = [ 35 | "netbox_kea", 36 | ] 37 | 38 | [tool.ruff] 39 | exclude = [ 40 | "netbox_kea/migrations", 41 | ] 42 | 43 | [tool.ruff.lint] 44 | select = [ 45 | "C4", 46 | "E", 47 | "EXE", 48 | "F", 49 | "I", 50 | "ISC", 51 | "PERF", 52 | "PIE", 53 | "PYI", 54 | "UP", 55 | "W", 56 | ] 57 | ignore = [ 58 | "E501", 59 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 60 | "W191", 61 | "E111", 62 | "E114", 63 | "E117", 64 | "D206", 65 | "D300", 66 | "Q000", 67 | "Q001", 68 | "Q002", 69 | "Q003", 70 | "COM812", 71 | "COM819", 72 | "ISC001", 73 | "ISC002", 74 | ] 75 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/inc/server_dhcp_leases_paginator.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 |
3 | {% if paginate %} 4 |
5 | Next 15 |
16 | Loading... 17 |
18 |
19 | {% endif %} 20 | Showing {{ table.rows|length }} lease(s) 21 | {% if paginate %} 22 | 34 | {% endif %} 35 |
36 | -------------------------------------------------------------------------------- /tests/docker/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log notice; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | #gzip on; 29 | 30 | server { 31 | listen 80; 32 | listen [::]:80; 33 | 34 | location / { 35 | proxy_pass http://kea-ctrl-agent:8000; 36 | auth_basic "Kea"; 37 | auth_basic_user_file /etc/nginx/htpasswd; 38 | } 39 | } 40 | server { 41 | listen 443 ssl; 42 | listen [::]:443 ssl; 43 | ssl_certificate /etc/nginx/certs/nginx.crt; 44 | ssl_certificate_key /etc/nginx/certs/nginx.key; 45 | 46 | location / { 47 | proxy_pass http://kea-ctrl-agent:8000; 48 | } 49 | } 50 | server { 51 | listen 444 ssl; 52 | listen [::]:444 ssl; 53 | ssl_certificate /etc/nginx/certs/nginx.crt; 54 | ssl_certificate_key /etc/nginx/certs/nginx.key; 55 | 56 | ssl_client_certificate /etc/nginx/certs/netbox.crt; 57 | ssl_verify_client on; 58 | 59 | location / { 60 | proxy_pass http://kea-ctrl-agent:8000; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /netbox_kea/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.8 on 2023-05-04 02:42 2 | 3 | from django.db import migrations, models 4 | import taggit.managers 5 | import utilities.json 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('extras', '0092_delete_jobresult'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Server', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 21 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 22 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 23 | ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), 24 | ('name', models.CharField(max_length=255, unique=True)), 25 | ('server_url', models.CharField(max_length=255)), 26 | ('username', models.CharField(blank=True, max_length=255, null=True)), 27 | ('password', models.CharField(blank=True, max_length=255, null=True)), 28 | ('ssl_verify', models.BooleanField(default=True)), 29 | ('client_cert_path', models.CharField(blank=True, max_length=4096, null=True)), 30 | ('client_key_path', models.CharField(blank=True, max_length=4096, null=True)), 31 | ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)), 32 | ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), 33 | ], 34 | options={ 35 | 'abstract': False, 36 | }, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | "on": 5 | push: 6 | branches: 7 | - "*" 8 | pull_request: 9 | schedule: 10 | - cron: 0 0 * * 0 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | 18 | - uses: astral-sh/ruff-action@v3 19 | 20 | - uses: astral-sh/ruff-action@v3 21 | with: 22 | args: format --check 23 | test: 24 | strategy: 25 | matrix: 26 | include: 27 | - netbox: v4.0 28 | - netbox: v4.1 29 | - netbox: v4.2 30 | - netbox: v4.3 31 | - netbox: v4.4 32 | 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v6 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v7 39 | with: 40 | enable-cache: true 41 | 42 | - name: Setup Python 3.12 43 | id: setup-python 44 | uses: actions/setup-python@v6 45 | with: 46 | python-version-file: pyproject.toml 47 | 48 | - name: Ensure playwright browsers are installed 49 | run: uv run playwright install --with-deps 50 | 51 | - name: Run uv build 52 | run: uv build 53 | 54 | - name: Run test_setup.sh 55 | run: ./tests/test_setup.sh 56 | env: 57 | NETBOX_CONTAINER_TAG: ${{ matrix.netbox }} 58 | 59 | - name: Run pytest 60 | run: | 61 | uv run pytest --tracing=retain-on-failure -v 62 | 63 | - name: Upload Playwright traces 64 | uses: actions/upload-artifact@v5 65 | if: ${{ !cancelled() }} 66 | with: 67 | name: playwright-traces 68 | path: test-results/ 69 | 70 | - name: Show Docker logs 71 | if: ${{ always() }} 72 | run: docker compose logs 73 | working-directory: ./tests/docker/ 74 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pynetbox 2 | import pytest 3 | import requests 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def netbox_url() -> str: 8 | return "http://localhost:8000" 9 | 10 | 11 | @pytest.fixture(scope="session") 12 | def netbox_token() -> str: 13 | return "0123456789abcdef0123456789abcdef01234567" 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def netbox_username() -> str: 18 | return "admin" 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | def netbox_password() -> str: 23 | return "admin" 24 | 25 | 26 | @pytest.fixture(scope="session") 27 | def kea_url() -> str: 28 | return "http://kea-ctrl-agent:8000" 29 | 30 | 31 | @pytest.fixture(scope="session") 32 | def nb_http(netbox_token: str) -> requests.Session: 33 | s = requests.Session() 34 | s.headers.update( 35 | { 36 | "Authorization": f"Token {netbox_token}", 37 | "Content-Type": "application/json", 38 | "Accept": "application/json", 39 | } 40 | ) 41 | return s 42 | 43 | 44 | @pytest.fixture(scope="session", autouse=True) 45 | def nb_api(netbox_url: str, netbox_token: str) -> pynetbox.api: 46 | api = pynetbox.api(netbox_url, token=netbox_token) 47 | api.plugins.kea.servers.delete(api.plugins.kea.servers.all()) 48 | 49 | return api 50 | 51 | 52 | @pytest.fixture 53 | def kea_basic_url() -> str: 54 | return "http://nginx" 55 | 56 | 57 | @pytest.fixture 58 | def kea_basic_username() -> str: 59 | return "kea" 60 | 61 | 62 | @pytest.fixture 63 | def kea_basic_password() -> str: 64 | return "kea" 65 | 66 | 67 | @pytest.fixture 68 | def kea_https_url() -> str: 69 | return "https://nginx" 70 | 71 | 72 | @pytest.fixture 73 | def kea_cert_url() -> str: 74 | return "https://nginx:444" 75 | 76 | 77 | @pytest.fixture 78 | def kea_client_cert() -> str: 79 | return "/certs/netbox.crt" 80 | 81 | 82 | @pytest.fixture 83 | def kea_client_key() -> str: 84 | return "/certs/netbox.key" 85 | 86 | 87 | @pytest.fixture 88 | def kea_ca() -> str: 89 | return "/certs/nginx.crt" 90 | -------------------------------------------------------------------------------- /netbox_kea/kea.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any, TypedDict 3 | 4 | import requests 5 | from requests.models import HTTPBasicAuth 6 | 7 | 8 | class KeaResponse(TypedDict): 9 | result: int 10 | arguments: dict[str, Any] | None 11 | text: str | None 12 | 13 | 14 | class KeaClient: 15 | def __init__( 16 | self, 17 | url: str, 18 | username: str | None = None, 19 | password: str | None = None, 20 | verify: bool | str | None = None, 21 | client_cert: str | None = None, 22 | client_key: str | None = None, 23 | timeout: int = 30, 24 | ): 25 | if (client_cert is not None and client_key is None) or ( 26 | client_cert is None and client_key is not None 27 | ): 28 | raise ValueError("Key and Cert must be used together.") 29 | 30 | self.url = url 31 | self.timeout = timeout 32 | 33 | self._session = requests.Session() 34 | if verify is not None: 35 | self._session.verify = verify 36 | if username is not None and password is not None: 37 | self._session.auth = HTTPBasicAuth(username, password) 38 | if client_cert is not None and client_key is not None: 39 | self._session.cert = (client_cert, client_key) 40 | 41 | def command( 42 | self, 43 | command: str, 44 | service: list[str] | None = None, 45 | arguments: dict[str, Any] | None = None, 46 | check: None | Sequence[int] = (0,), 47 | ) -> list[KeaResponse]: 48 | body: dict[str, Any] = {"command": command} 49 | 50 | if service is not None: 51 | body["service"] = service 52 | 53 | if arguments is not None: 54 | body["arguments"] = arguments 55 | 56 | resp = self._session.post(self.url, json=body, timeout=self.timeout) 57 | resp.raise_for_status() 58 | resp_json = resp.json() 59 | assert isinstance(resp_json, list) 60 | if check is not None: 61 | check_response(resp_json, check) 62 | return resp_json 63 | 64 | 65 | class KeaException(Exception): 66 | def __init__( 67 | self, resp: KeaResponse, msg: str | None = None, index: int | None = None 68 | ) -> None: 69 | self.index = index 70 | self.response = resp 71 | 72 | if msg is None: 73 | msg = f"Kea returned result[{index}] {self.response.get('result')}" 74 | message = f"{msg}: {self.response.get('text')}" 75 | super().__init__(message) 76 | 77 | 78 | def check_response(resp: list[KeaResponse], ok_codes: Sequence[int]) -> None: 79 | """Raise a KeaException for any non 0 responses.""" 80 | for idx, kr in enumerate(resp): 81 | if kr["result"] not in ok_codes: 82 | raise KeaException(kr, index=idx) 83 | -------------------------------------------------------------------------------- /netbox_kea/utilities.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections.abc import Callable 3 | from datetime import datetime 4 | from typing import Any, Literal 5 | 6 | from django.http import HttpResponse 7 | from django.shortcuts import redirect 8 | from django_tables2 import Table 9 | from django_tables2.export import TableExport 10 | from utilities.views import ViewTab 11 | 12 | from . import constants 13 | from .models import Server 14 | 15 | 16 | def format_duration(s: int | None) -> str | None: 17 | if s is None: 18 | return None 19 | hours, rest = divmod(s, 3600) 20 | minutes, seconds = divmod(rest, 60) 21 | return f"{hours:02}:{minutes:02}:{seconds:02}" 22 | 23 | 24 | def _enrich_lease(now: datetime, lease: dict[str, Any]) -> dict[str, Any]: 25 | """Add expires at and expires in to a lease.""" 26 | 27 | # Need to replace "-" so we can access the values in a template 28 | lease = {k.replace("-", "_"): v for k, v in lease.items()} 29 | if "cltt" not in lease and "valid_lft" not in lease: 30 | return lease 31 | 32 | # https://kea.readthedocs.io/en/kea-2.2.0/arm/hooks.html?highlight=cltt#the-lease4-get-lease6-get-commands 33 | cltt = lease["cltt"] 34 | valid_lft = lease["valid_lft"] 35 | assert isinstance(cltt, int) 36 | assert isinstance(valid_lft, int) 37 | expires_at = datetime.fromtimestamp(cltt + valid_lft) 38 | lease["expires_at"] = expires_at 39 | lease["expires_in"] = (expires_at - now).seconds 40 | lease["cltt"] = datetime.fromtimestamp(cltt) 41 | return lease 42 | 43 | 44 | def format_leases(leases: list[dict[str, Any]]) -> list[dict[str, Any]]: 45 | now = datetime.now() 46 | return [_enrich_lease(now, ls) for ls in leases] 47 | 48 | 49 | def export_table( 50 | table: Table, 51 | filename: str, 52 | use_selected_columns: bool = False, 53 | ) -> HttpResponse: 54 | exclude_columns = {"pk", "actions"} 55 | 56 | if use_selected_columns: 57 | exclude_columns |= {name for name, _ in table.available_columns} 58 | 59 | exporter = TableExport( 60 | export_format=TableExport.CSV, 61 | table=table, 62 | exclude_columns=exclude_columns, 63 | ) 64 | return exporter.response(filename=filename) 65 | 66 | 67 | def is_hex_string(s: str, min_octets: int, max_octets: int): 68 | if not re.match(constants.HEX_STRING_REGEX, s): 69 | return False 70 | 71 | octets = len(s.replace(":", "").replace("-", "")) / 2 72 | return octets >= min_octets and octets <= max_octets 73 | 74 | 75 | def check_dhcp_enabled(instance: Server, version: Literal[6, 4]) -> HttpResponse | None: 76 | if (version == 6 and instance.dhcp6) or (version == 4 and instance.dhcp4): 77 | return None 78 | return redirect(instance.get_absolute_url()) 79 | 80 | 81 | class OptionalViewTab(ViewTab): 82 | def __init__(self, *args, is_enabled: Callable[[Any], bool], **kwargs) -> None: 83 | self.is_enabled = is_enabled 84 | super().__init__(*args, **kwargs) 85 | 86 | def render(self, instance): 87 | if self.is_enabled(instance): 88 | return super().render(instance) 89 | return None 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox plugin for the Kea DHCP server 2 | 3 | This plugin allows you to view Kea status, leases and subnets in NetBox. Go directly from a NetBox device/VM to a DHCP lease and back! 4 | 5 | ## Features 6 | 7 | - Uses the Kea management API 8 | - View Kea daemon statuses. 9 | - Supports Kea's DHCPv4 and DHCPv6 servers. 10 | - View, delete, export and search for DHCP leases. 11 | - Search for NetBox devices/VMs directly from DHCP leases. 12 | - View DHCP subnets from Kea's configuration. 13 | - REST API and GraphQL support for managing Server objects. 14 | 15 | ![Screenshot of DHCP leases](images/leases.png) 16 | 17 | ## Limitations 18 | 19 | - Due to limitations in the Kea management API, pagination is only supported when searching for leases by subnet. 20 | Additionally, you can only go forwards, not backwards. 21 | 22 | - Searching for leases by subnet ID does not support pagination. This may be an expensive operation depending on the subnet size. 23 | 24 | - Kea doesn't provide a way to get a list of subnets without an additional hook library. 25 | Thus, this plugin lists subnets using the `config-get` command. This means that the entire config will be fetched just to get the configured subnets! 26 | This may be an expensive operation. 27 | 28 | ## Requirements 29 | 30 | - NetBox 4.0, 4.1, 4.2, 4.3 or 4.4 31 | - [Kea Control Agent](https://kea.readthedocs.io/en/latest/arm/agent.html) 32 | - [`lease_cmds`](https://kea.readthedocs.io/en/latest/arm/hooks.html#lease-cmds-lease-commands-for-easier-lease-management) hook library 33 | 34 | ## Compatibility 35 | 36 | - This plugin is tested with Kea v2.4.1 with the `memfile` lease database. 37 | Other versions and lease databases may also work. 38 | 39 | ## Installation 40 | 41 | 1. Add `netbox-kea` to `local_requirements.txt`. 42 | 43 | 2. Enable the plugin in `configuration.py` 44 | ```python 45 | PLUGINS = ["netbox_kea"] 46 | ``` 47 | 3. Run `./manage.py migrate` 48 | 49 | ## Custom Links 50 | 51 | You can add custom links to NetBox models to easily search for leases. 52 | 53 | Make sure to replace `` in the link URL with the object ID of your Kea server. To find a server's ID, open the page for the server 54 | and look at the top right corner for `netbox_kea.server:`. 55 | 56 | ### Show DHCP leases for a prefix 57 | 58 | **Content types**: `IPAM > Prefix` 59 | 60 | **Link URL**: `https://netbox.example.com/plugins/kea/servers//leases{{ object.prefix.version }}/?q={{ object.prefix }}&by=subnet` 61 | 62 | ### Show DHCP leases for a device/VM interface (by MAC): 63 | 64 | **Content types**: `DCIM > Interface`, `Virtualization > Interface` 65 | 66 | **Link URL (DHCPv4)**: `https://netbox.example.com/plugins/kea/servers//leases4/?q={{ object.mac_address }}&by=hw` 67 | 68 | **Link URL (DHCPv6)**: `https://netbox.example.com/plugins/kea/servers//leases6/?q={{ object.mac_address }}&by=hw` 69 | 70 | ### Show DHCP leases for a device/VM (by name): 71 | 72 | **Content types**: `DCIM > Device`, `Virtualization > Virtual Machine` 73 | 74 | **Link URL (DHCPv4)**: `https://netbox.example.com/plugins/kea/servers//leases4/?q={{ object.name|lower }}&by=hostname` 75 | 76 | **Link URL (DHCPv4)**: `https://netbox.example.com/plugins/kea/servers//leases6/?q={{ object.name|lower }}&by=hostname` 77 | 78 | You may also use a custom field by replacing `{{ object.name|lower }}` with `{{ object.cf.|lower }}`. 79 | -------------------------------------------------------------------------------- /netbox_kea/templates/netbox_kea/server_dhcp_leases_htmx.html: -------------------------------------------------------------------------------- 1 | {% load form_helpers %} 2 | {% load helpers %} 3 | {% load render_table from django_tables2 %} 4 | 5 | 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | tests/files/certs 163 | -------------------------------------------------------------------------------- /tests/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.4" 3 | services: 4 | netbox: &netbox 5 | build: . 6 | depends_on: 7 | - postgres 8 | - redis 9 | - redis-cache 10 | user: "unit:root" 11 | ports: 12 | - 8000:8080 13 | healthcheck: 14 | start_period: 300s 15 | timeout: 3s 16 | interval: 15s 17 | test: curl -f http://localhost:8080/login/ || exit 1 18 | environment: 19 | CORS_ORIGIN_ALLOW_ALL: "true" 20 | DB_HOST: postgres 21 | DB_NAME: &postgres_db netbox 22 | DB_PASSWORD: &postgres_password J5brHrAXFLQSif0K 23 | DB_USER: &postgres_user netbox 24 | GRAPHQL_ENABLED: "true" 25 | HOUSEKEEPING_INTERVAL: 86400 26 | METRICS_ENABLED: "false" 27 | REDIS_CACHE_DATABASE: 1 28 | REDIS_CACHE_HOST: redis-cache 29 | REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY: "false" 30 | REDIS_CACHE_PASSWORD: &redis_cache_password t4Ph722qJ5QHeQ1qfu36 31 | REDIS_CACHE_SSL: "false" 32 | REDIS_DATABASE: 0 33 | REDIS_HOST: redis 34 | REDIS_INSECURE_SKIP_TLS_VERIFY: "false" 35 | REDIS_PASSWORD: &redis_password H733Kdjndks81 36 | REDIS_SSL: "false" 37 | SECRET_KEY: "r(m)9nLGnz$$(_q3N4z1k(EFsMCjjjzx08x9VhNVcfd%6RF#r!6DE@+V5Zk2X" 38 | WEBHOOKS_ENABLED: "true" 39 | SKIP_SUPERUSER: "false" 40 | SUPERUSER_API_TOKEN: "0123456789abcdef0123456789abcdef01234567" 41 | SUPERUSER_EMAIL: "admin" 42 | SUPERUSER_NAME: "admin" 43 | SUPERUSER_PASSWORD: "admin" 44 | LOGLEVEL: DEBUG 45 | DEBUG: "true" 46 | DB_WAIT_DEBUG: "1" 47 | volumes: 48 | - ./plugins.py:/etc/netbox/config/plugins.py:z,ro 49 | - ./certs:/certs/:ro 50 | netbox-worker: 51 | <<: *netbox 52 | depends_on: 53 | netbox: 54 | condition: service_healthy 55 | ports: [] 56 | command: 57 | - /opt/netbox/venv/bin/python 58 | - /opt/netbox/netbox/manage.py 59 | - rqworker 60 | healthcheck: 61 | start_period: 20s 62 | timeout: 3s 63 | interval: 15s 64 | test: "ps -aux | grep -v grep | grep -q rqworker || exit 1" 65 | netbox-housekeeping: 66 | <<: *netbox 67 | depends_on: 68 | netbox: 69 | condition: service_healthy 70 | ports: [] 71 | command: 72 | - /opt/netbox/housekeeping.sh 73 | healthcheck: 74 | start_period: 20s 75 | timeout: 3s 76 | interval: 15s 77 | test: "ps -aux | grep -v grep | grep -q housekeeping || exit 1" 78 | 79 | # postgres 80 | postgres: 81 | image: docker.io/postgres:16-alpine 82 | volumes: 83 | - netbox-postgres-data:/var/lib/postgresql/data 84 | environment: 85 | POSTGRES_DB: *postgres_db 86 | POSTGRES_PASSWORD: *postgres_password 87 | POSTGRES_USER: *postgres_user 88 | 89 | # redis 90 | redis: 91 | image: &redis-image docker.io/valkey/valkey:8.0-alpine 92 | command: 93 | - sh 94 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 95 | - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 96 | volumes: 97 | - netbox-redis-data:/data 98 | environment: 99 | REDIS_PASSWORD: *redis_password 100 | healthcheck: &redis-healthcheck 101 | test: '[ $$(valkey-cli --pass "$${REDIS_PASSWORD}" ping) = ''PONG'' ]' 102 | start_period: 5s 103 | timeout: 3s 104 | interval: 1s 105 | retries: 5 106 | 107 | redis-cache: 108 | image: *redis-image 109 | command: 110 | - sh 111 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 112 | - redis-server --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 113 | volumes: 114 | - netbox-redis-cache-data:/data 115 | environment: 116 | REDIS_PASSWORD: *redis_cache_password 117 | healthcheck: *redis-healthcheck 118 | 119 | volumes: 120 | netbox-postgres-data: 121 | driver: local 122 | netbox-redis-cache-data: 123 | driver: local 124 | netbox-redis-data: 125 | driver: local 126 | -------------------------------------------------------------------------------- /netbox_kea/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ValidationError 5 | from django.db import models 6 | from django.urls import reverse 7 | from netbox.models import NetBoxModel 8 | 9 | from .kea import KeaClient 10 | 11 | 12 | class Server(NetBoxModel): 13 | name = models.CharField(unique=True, max_length=255) 14 | server_url = models.CharField(verbose_name="Server URL", max_length=255) 15 | username = models.CharField(null=True, blank=True, max_length=255) 16 | password = models.CharField(null=True, blank=True, max_length=255) 17 | ssl_verify = models.BooleanField( 18 | default=True, 19 | verbose_name="SSL Verification", 20 | help_text="Enable SSL certificate verification. Disable with caution!", 21 | ) 22 | client_cert_path = models.CharField( 23 | max_length=4096, 24 | null=True, 25 | blank=True, 26 | verbose_name="Client Certificate", 27 | help_text="Optional client certificate.", 28 | ) 29 | client_key_path = models.CharField( 30 | max_length=4096, 31 | null=True, 32 | blank=True, 33 | verbose_name="Private Key", 34 | help_text="Optional client key.", 35 | ) 36 | ca_file_path = models.CharField( 37 | max_length=4096, 38 | null=True, 39 | blank=True, 40 | verbose_name="CA File Path", 41 | help_text="The specific CA certificate file to use for SSL verification.", 42 | ) 43 | dhcp6 = models.BooleanField(verbose_name="DHCPv6", default=True) 44 | dhcp4 = models.BooleanField(verbose_name="DHCPv4", default=True) 45 | 46 | class Meta: 47 | ordering = ("name",) 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | def get_absolute_url(self): 53 | return reverse("plugins:netbox_kea:server", args=[self.pk]) 54 | 55 | def get_client(self) -> KeaClient: 56 | return KeaClient( 57 | url=self.server_url, 58 | username=self.username, 59 | password=self.password, 60 | verify=self.ca_file_path or self.ssl_verify, 61 | client_cert=self.client_cert_path or None, 62 | client_key=self.client_key_path or None, 63 | timeout=settings.PLUGINS_CONFIG["netbox_kea"]["kea_timeout"], 64 | ) 65 | 66 | def clean(self) -> None: 67 | super().clean() 68 | 69 | if self.dhcp4 is False and self.dhcp6 is False: 70 | raise ValidationError( 71 | {"dhcp6": "At one of DHCPv4 and DHCPv6 needs to be enabled."} 72 | ) 73 | 74 | if (self.client_cert_path and not self.client_key_path) or ( 75 | not self.client_cert_path and self.client_key_path 76 | ): 77 | raise ValidationError( 78 | { 79 | "client_cert_path": "Client certificate and client private key must be used together." 80 | } 81 | ) 82 | 83 | if self.client_cert_path and not os.path.isfile(self.client_cert_path): 84 | raise ValidationError( 85 | {"client_cert_path": "Client certificate doesn't exist."} 86 | ) 87 | if self.client_key_path and not os.path.isfile(self.client_key_path): 88 | raise ValidationError( 89 | {"client_key_path": "Client private key doesn't exist."} 90 | ) 91 | 92 | if self.ca_file_path and not self.ssl_verify: 93 | raise ValidationError( 94 | { 95 | "ca_file_path": "Cannot specify a CA file when SSL verification is disabled." 96 | } 97 | ) 98 | 99 | client = self.get_client() 100 | if self.dhcp6: 101 | try: 102 | client.command("version-get", service=["dhcp6"]) 103 | except Exception as e: 104 | raise ValidationError( 105 | {"dhcp6": f"Unable to get DHCPv6 version: {repr(e)}"} 106 | ) from e 107 | if self.dhcp4: 108 | try: 109 | client.command("version-get", service=["dhcp4"]) 110 | except Exception as e: 111 | raise ValidationError( 112 | {"dhcp4": f"Unable to get DHCPv4 version: {repr(e)}"} 113 | ) from e 114 | -------------------------------------------------------------------------------- /tests/docker/kea_configs/kea-ctrl-agent.conf: -------------------------------------------------------------------------------- 1 | // This is a basic configuration for the Kea Control Agent. 2 | // 3 | // This is just a very basic configuration. Kea comes with large suite (over 30) 4 | // of configuration examples and extensive Kea User's Guide. Please refer to 5 | // those materials to get better understanding of what this software is able to 6 | // do. Comments in this configuration file sometimes refer to sections for more 7 | // details. These are section numbers in Kea User's Guide. The version matching 8 | // your software should come with your Kea package, but it is also available 9 | // in ISC's Knowledgebase (https://kea.readthedocs.io; the direct link for 10 | // the stable version is https://kea.readthedocs.io/). 11 | // 12 | // This configuration file contains only Control Agent's configuration. 13 | // If configurations for other Kea services are also included in this file they 14 | // are ignored by the Control Agent. 15 | { 16 | 17 | // This is a basic configuration for the Kea Control Agent. 18 | // RESTful interface to be available at http://127.0.0.1:8000/ 19 | "Control-agent": { 20 | "http-host": "0.0.0.0", 21 | // If enabling HA and multi-threading, the 8000 port is used by the HA 22 | // hook library http listener. When using HA hook library with 23 | // multi-threading to function, make sure the port used by dedicated 24 | // listener is different (e.g. 8001) than the one used by CA. Note 25 | // the commands should still be sent via CA. The dedicated listener 26 | // is specifically for HA updates only. 27 | "http-port": 8000, 28 | 29 | // Specify location of the files to which the Control Agent 30 | // should connect to forward commands to the DHCPv4, DHCPv6 31 | // and D2 servers via unix domain sockets. 32 | "control-sockets": { 33 | "dhcp4": { 34 | "socket-type": "unix", 35 | "socket-name": "/run/kea/kea-dhcp4-ctrl.sock" 36 | }, 37 | "dhcp6": { 38 | "socket-type": "unix", 39 | "socket-name": "/run/kea/kea-dhcp6-ctrl.sock" 40 | }, 41 | }, 42 | 43 | // Specify hooks libraries that are attached to the Control Agent. 44 | // Such hooks libraries should support 'control_command_receive' 45 | // hook point. This is currently commented out because it has to 46 | // point to the existing hooks library. Otherwise the Control 47 | // Agent will fail to start. 48 | "hooks-libraries": [ 49 | // { 50 | // "library": "/usr/lib/kea/hooks/control-agent-commands.so", 51 | // "parameters": { 52 | // "param1": "foo" 53 | // } 54 | // } 55 | ], 56 | 57 | // Logging configuration starts here. Kea uses different loggers to log various 58 | // activities. For details (e.g. names of loggers), see Chapter 18. 59 | "loggers": [ 60 | { 61 | // This specifies the logging for Control Agent daemon. 62 | "name": "kea-ctrl-agent", 63 | "output_options": [ 64 | { 65 | // Specifies the output file. There are several special values 66 | // supported: 67 | // - stdout (prints on standard output) 68 | // - stderr (prints on standard error) 69 | // - syslog (logs to syslog) 70 | // - syslog:name (logs to syslog using specified name) 71 | // Any other value is considered a name of the file 72 | "output": "stdout", 73 | 74 | // Shorter log pattern suitable for use with systemd, 75 | // avoids redundant information 76 | // "pattern": "%-5p %m\n" 77 | 78 | // This governs whether the log output is flushed to disk after 79 | // every write. 80 | // "flush": false, 81 | 82 | // This specifies the maximum size of the file before it is 83 | // rotated. 84 | // "maxsize": 1048576, 85 | 86 | // This specifies the maximum number of rotated files to keep. 87 | // "maxver": 8 88 | } 89 | ], 90 | // This specifies the severity of log messages to keep. Supported values 91 | // are: FATAL, ERROR, WARN, INFO, DEBUG 92 | "severity": "INFO", 93 | 94 | // If DEBUG level is specified, this value is used. 0 is least verbose, 95 | // 99 is most verbose. Be cautious, Kea can generate lots and lots 96 | // of logs if told to do so. 97 | "debuglevel": 0 98 | } 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /netbox_kea/forms.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from django import forms 4 | from django.core.exceptions import ValidationError 5 | from netaddr import EUI, AddrFormatError, IPAddress, IPNetwork, mac_unix_expanded 6 | from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm 7 | from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES 8 | from utilities.forms.fields import TagFilterField 9 | 10 | from . import constants 11 | from .models import Server 12 | from .utilities import is_hex_string 13 | 14 | 15 | class ServerForm(NetBoxModelForm): 16 | class Meta: 17 | model = Server 18 | fields = ( 19 | "name", 20 | "server_url", 21 | "username", 22 | "password", 23 | "ssl_verify", 24 | "client_cert_path", 25 | "client_key_path", 26 | "ca_file_path", 27 | "dhcp6", 28 | "dhcp4", 29 | "tags", 30 | ) 31 | 32 | 33 | class VeryHiddenInput(forms.HiddenInput): 34 | """Returns an empty string on render.""" 35 | 36 | input_type = "hidden" 37 | template_name = "" 38 | 39 | def render(self, name: str, value: Any, attrs: Any, renderer: Any) -> str: 40 | return "" 41 | 42 | 43 | class ServerFilterForm(NetBoxModelFilterSetForm): 44 | model = Server 45 | tag = TagFilterField(model) 46 | dhcp4 = forms.NullBooleanField( 47 | label="DHCPv4", 48 | required=False, 49 | widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), 50 | ) 51 | dhcp6 = forms.NullBooleanField( 52 | label="DHCPv6", 53 | required=False, 54 | widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), 55 | ) 56 | 57 | 58 | class BaseLeasesSarchForm(forms.Form): 59 | q = forms.CharField(label="Search") 60 | page = forms.CharField(required=False, widget=VeryHiddenInput) 61 | 62 | def clean(self) -> dict[str, Any] | None: 63 | ip_version = self.Meta.ip_version 64 | cleaned_data = super().clean() 65 | q = cleaned_data.get("q") 66 | by = cleaned_data.get("by") 67 | 68 | if q and not by: 69 | raise ValidationError({"by": "Search attribute is empty."}) 70 | elif by and not q: 71 | raise ValidationError({"q": "Search value is empty."}) 72 | 73 | if by == constants.BY_SUBNET: 74 | try: 75 | if "/" not in q: 76 | raise ValidationError({"q": "CIDR mask is required"}) 77 | net = IPNetwork(q, version=ip_version) 78 | if net.ip != net.cidr.ip: 79 | raise ValidationError( 80 | {"q": f"{net} is not a valid prefix. Did you mean {net.cidr}?"} 81 | ) 82 | cleaned_data["q"] = net 83 | except (AddrFormatError, TypeError, ValueError) as e: 84 | raise ValidationError({"q": f"Invalid IPv{ip_version} subnet."}) from e 85 | elif by == constants.BY_SUBNET_ID: 86 | try: 87 | i = int(q) 88 | if i <= 0: 89 | raise ValidationError({"q": "Invalid subnet ID."}) 90 | cleaned_data["q"] = i 91 | except ValueError as e: 92 | raise ValidationError({"q": "Subnet ID must be an integer."}) from e 93 | elif by == constants.BY_IP: 94 | try: 95 | # use IPAddress to normalize values 96 | cleaned_data["q"] = str(IPAddress(q, version=ip_version)) 97 | except (AddrFormatError, TypeError, ValueError) as e: 98 | raise ValidationError({"q": f"Invalid IPv{ip_version} address."}) from e 99 | elif by == constants.BY_HW_ADDRESS: 100 | try: 101 | cleaned_data["q"] = str(EUI(q, version=48, dialect=mac_unix_expanded)) 102 | except (AddrFormatError, TypeError, ValueError) as e: 103 | raise ValidationError({"q": "Invalid hardware address."}) from e 104 | elif by == constants.BY_DUID: 105 | if not is_hex_string( 106 | q, constants.DUID_MIN_OCTETS, constants.DUID_MAX_OCTETS 107 | ): 108 | raise ValidationError({"q": "Invalid DUID."}) 109 | cleaned_data["q"] = q.replace("-", "") 110 | elif by == constants.BY_CLIENT_ID: 111 | if not is_hex_string( 112 | q, constants.CLIENT_ID_MIN_OCTETS, constants.DUID_MAX_OCTETS 113 | ): 114 | raise ValidationError({"q": "Invalid client ID."}) 115 | cleaned_data["q"] = q.replace("-", "") 116 | 117 | page = cleaned_data["page"] 118 | if page: 119 | if by != constants.BY_SUBNET: 120 | raise ValidationError({"page": "page is only supported with subnet."}) 121 | try: 122 | page_ip = IPAddress(page, version=ip_version) 123 | if page_ip not in cleaned_data["q"]: 124 | raise ValidationError({"page": "page is not in the given subnet"}) 125 | 126 | cleaned_data["page"] = str(page_ip) 127 | except AddrFormatError as e: 128 | raise ValidationError({"page": "Invalid IP."}) from e 129 | 130 | 131 | class Leases4SearchForm(BaseLeasesSarchForm): 132 | by = forms.ChoiceField( 133 | label="Attribute", 134 | choices=( 135 | (constants.BY_IP, "IP Address"), 136 | (constants.BY_HOSTNAME, "Hostname"), 137 | (constants.BY_HW_ADDRESS, "Hardware Address"), 138 | (constants.BY_CLIENT_ID, "Client ID"), 139 | (constants.BY_SUBNET, "Subnet"), 140 | (constants.BY_SUBNET_ID, "Subnet ID"), 141 | ), 142 | required=True, 143 | ) 144 | 145 | class Meta: 146 | ip_version = 4 147 | 148 | 149 | class Leases6SearchForm(BaseLeasesSarchForm): 150 | by = forms.ChoiceField( 151 | label="Attribute", 152 | choices=( 153 | (constants.BY_IP, "IP Address"), 154 | (constants.BY_HOSTNAME, "Hostname"), 155 | (constants.BY_DUID, "DUID"), 156 | (constants.BY_SUBNET, "Subnet"), 157 | (constants.BY_SUBNET_ID, "Subnet ID"), 158 | ), 159 | required=True, 160 | ) 161 | 162 | class Meta: 163 | ip_version = 6 164 | 165 | 166 | class MultipleIPField(forms.MultipleChoiceField): 167 | def __init__(self, version: Literal[6, 4], *args, **kwargs) -> None: 168 | self._version = version 169 | super().__init__(*args, widget=forms.MultipleHiddenInput, **kwargs) 170 | 171 | def clean(self, value: Any) -> Any: 172 | if not isinstance(value, list): 173 | raise forms.ValidationError(f"Expected a list, got {type(value)}.") 174 | 175 | if len(value) == 0: 176 | raise forms.ValidationError("IP address list is empty.") 177 | 178 | try: 179 | return [str(IPAddress(ip, version=self._version)) for ip in value] 180 | except (AddrFormatError, ValueError) as e: 181 | raise forms.ValidationError("Invalid IP address.") from e 182 | 183 | 184 | class BaseLeaseDeleteForm(forms.Form): 185 | # NetBox v4.4 requires a background_job field for the bulk_delete.html 186 | # template. 187 | background_job = forms.CharField( 188 | required=False, widget=VeryHiddenInput, label="background_job" 189 | ) 190 | return_url = forms.CharField( 191 | required=False, 192 | widget=forms.HiddenInput(), 193 | ) 194 | 195 | 196 | class Lease6DeleteForm(BaseLeaseDeleteForm): 197 | pk = MultipleIPField(6) 198 | 199 | 200 | class Lease4DeleteForm(BaseLeaseDeleteForm): 201 | pk = MultipleIPField(4) 202 | -------------------------------------------------------------------------------- /tests/test_netbox_kea_api_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pynetbox 4 | import pytest 5 | import requests 6 | from pynetbox.core.query import RequestError 7 | 8 | 9 | def test_server_api_add_delete(nb_api: pynetbox.api): 10 | name = "test" 11 | server_url = "http://kea-ctrl-agent:8000" 12 | 13 | server = nb_api.plugins.kea.servers.create(name=name, server_url=server_url) 14 | assert server.name == name 15 | assert server.server_url == server_url 16 | 17 | # We shouldn't be able to add a server with the same name 18 | with pytest.raises(RequestError): 19 | nb_api.plugins.kea.servers.create( 20 | name=name, server_url="http://kea-ctrl-agent:8000" 21 | ) 22 | 23 | new_name = "new-name" 24 | server.update({"name": new_name}) 25 | new_server = nb_api.plugins.kea.servers.get(name=new_name) 26 | assert new_server.name == new_name 27 | 28 | assert server.delete() is True 29 | 30 | 31 | def test_server_api_bulk_actions(nb_api: pynetbox.api): 32 | servers = nb_api.plugins.kea.servers.create( 33 | [ 34 | {"name": "server1", "server_url": "http://kea-ctrl-agent:8000"}, 35 | {"name": "server2", "server_url": "http://kea-ctrl-agent:8000"}, 36 | ] 37 | ) 38 | for s in servers: 39 | s.name += "-updated" 40 | nb_api.plugins.kea.servers.update(servers) 41 | 42 | assert nb_api.plugins.kea.servers.get(name="server1-updated") is not None 43 | assert nb_api.plugins.kea.servers.delete(servers) is True 44 | 45 | 46 | def test_graphql(nb_api: pynetbox.api, nb_http: requests.Session): 47 | server = nb_api.plugins.kea.servers.create( 48 | name="gql-test", server_url="http://kea-ctrl-agent:8000" 49 | ) 50 | r = nb_http.post( 51 | "http://localhost:8000/graphql/", 52 | json={ 53 | "query": """ 54 | { 55 | server_list { 56 | id 57 | name 58 | server_url 59 | } 60 | } 61 | """ 62 | }, 63 | ) 64 | assert r.ok is True 65 | 66 | r_json = r.json() 67 | assert r_json == { 68 | "data": { 69 | "server_list": [ 70 | { 71 | "id": str(server.id), 72 | "name": server.name, 73 | "server_url": server.server_url, 74 | } 75 | ] 76 | } 77 | } 78 | 79 | r = nb_http.post( 80 | "http://localhost:8000/graphql/", 81 | json={ 82 | "query": """ 83 | { 84 | server(id: %s) { 85 | id 86 | name 87 | server_url 88 | } 89 | } 90 | """ # noqa: UP031 91 | % server.id 92 | }, 93 | ) 94 | assert r.ok is True 95 | r_json = r.json() 96 | assert r_json == { 97 | "data": { 98 | "server": { 99 | "id": str(server.id), 100 | "name": server.name, 101 | "server_url": server.server_url, 102 | } 103 | } 104 | } 105 | 106 | 107 | @pytest.mark.parametrize( 108 | ("body",), 109 | ( 110 | pytest.param( 111 | { 112 | "name": "cert-no-key", 113 | "server_url": "http://kea-ctrl-agent:8000", 114 | "client_cert_path": "/root/mycert.crt", 115 | }, 116 | id="client-cert-no-key", 117 | ), 118 | ), 119 | ) 120 | def test_api_add_failures(body: dict[str, Any], nb_api: pynetbox.api): 121 | with pytest.raises(RequestError): 122 | nb_api.plugins.kea.servers.create(**body) 123 | 124 | 125 | def test_server_create_basic_auth( 126 | nb_api: pynetbox.api, 127 | kea_basic_url: str, 128 | kea_basic_username: str, 129 | kea_basic_password: str, 130 | ) -> None: 131 | nb_api.plugins.kea.servers.create( 132 | name="basic", 133 | server_url=kea_basic_url, 134 | username=kea_basic_username, 135 | password=kea_basic_password, 136 | ) 137 | 138 | 139 | def test_server_create_client_cert( 140 | nb_api: pynetbox.api, 141 | kea_cert_url: str, 142 | kea_client_cert: str, 143 | kea_client_key: str, 144 | kea_ca: str, 145 | ) -> None: 146 | nb_api.plugins.kea.servers.create( 147 | name="client_cert", 148 | server_url=kea_cert_url, 149 | client_cert_path=kea_client_cert, 150 | client_key_path=kea_client_key, 151 | ca_file_path=kea_ca, 152 | ) 153 | 154 | 155 | def test_server_create_invalid_key( 156 | nb_api: pynetbox.api, 157 | kea_cert_url: str, 158 | kea_client_cert: str, 159 | kea_ca: str, 160 | ) -> None: 161 | with pytest.raises(RequestError): 162 | nb_api.plugins.kea.servers.create( 163 | name="client_cert", 164 | server_url=kea_cert_url, 165 | client_cert_path=kea_client_cert, 166 | client_key_path="foo", 167 | ca_file_path=kea_ca, 168 | ) 169 | 170 | 171 | def test_server_create_invalid_cert( 172 | nb_api: pynetbox.api, 173 | kea_cert_url: str, 174 | kea_client_key: str, 175 | kea_ca: str, 176 | ) -> None: 177 | with pytest.raises(RequestError): 178 | nb_api.plugins.kea.servers.create( 179 | name="client_cert", 180 | server_url=kea_cert_url, 181 | client_cert_path="foo", 182 | client_key_path=kea_client_key, 183 | ca_file_path=kea_ca, 184 | ) 185 | 186 | 187 | def test_server_create_key_no_cert( 188 | nb_api: pynetbox.api, 189 | kea_cert_url: str, 190 | kea_client_key: str, 191 | kea_ca: str, 192 | ) -> None: 193 | with pytest.raises(RequestError): 194 | nb_api.plugins.kea.servers.create( 195 | name="client_cert", 196 | server_url=kea_cert_url, 197 | client_key_path=kea_client_key, 198 | ca_file_path=kea_ca, 199 | ) 200 | 201 | 202 | def test_server_create_cert_no_key( 203 | nb_api: pynetbox.api, 204 | kea_cert_url: str, 205 | kea_client_cert: str, 206 | kea_ca: str, 207 | ) -> None: 208 | with pytest.raises(RequestError): 209 | nb_api.plugins.kea.servers.create( 210 | name="client_cert", 211 | server_url=kea_cert_url, 212 | client_cert_path=kea_client_cert, 213 | ca_file_path=kea_ca, 214 | ) 215 | 216 | 217 | def test_server_create_https( 218 | nb_api: pynetbox.api, kea_https_url: str, kea_ca: str 219 | ) -> None: 220 | nb_api.plugins.kea.servers.create( 221 | name="https", 222 | server_url=kea_https_url, 223 | ca_file_path=kea_ca, 224 | ) 225 | 226 | 227 | def test_server_create_ca_ssl_verify_false( 228 | nb_api: pynetbox.api, kea_https_url: str, kea_ca: str 229 | ) -> None: 230 | with pytest.raises(RequestError): 231 | nb_api.plugins.kea.servers.create( 232 | name="https", 233 | server_url=kea_https_url, 234 | ca_file_path=kea_ca, 235 | ssl_verify=False, 236 | ) 237 | 238 | 239 | def test_server_create_untrusted(nb_api: pynetbox.api, kea_https_url: str) -> None: 240 | with pytest.raises(RequestError): 241 | nb_api.plugins.kea.servers.create( 242 | name="https", 243 | server_url=kea_https_url, 244 | ) 245 | 246 | 247 | def test_server_create_no_ssl_verify( 248 | nb_api: pynetbox.api, 249 | kea_https_url: str, 250 | ) -> None: 251 | nb_api.plugins.kea.servers.create( 252 | name="insecure", 253 | server_url=kea_https_url, 254 | ssl_verify=False, 255 | ) 256 | 257 | 258 | def test_server_create_dhcp4_false_dhcp6_false( 259 | nb_api: pynetbox.api, kea_url: str 260 | ) -> None: 261 | with pytest.raises(RequestError): 262 | nb_api.plugins.kea.servers.create( 263 | name="no-services-enabled", 264 | server_url="http://kea-ctrl-agent:8000", 265 | dhcp4=False, 266 | dhcp6=False, 267 | ) 268 | -------------------------------------------------------------------------------- /netbox_kea/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | from django.urls import reverse 3 | from django.utils.http import urlencode 4 | from netbox.tables import BaseTable, BooleanColumn, NetBoxTable, ToggleColumn, columns 5 | 6 | from netbox_kea.utilities import format_duration 7 | 8 | from .models import Server 9 | 10 | SUBNET_ACTIONS = """ 11 | 12 | 13 | 31 | 32 | """ # noqa: E501 33 | 34 | 35 | LEASE_ACTIONS = """ 36 | 37 | 38 | 76 | 77 | """ # noqa: E501 78 | 79 | 80 | class DurationColumn(tables.Column): 81 | def render(self, value: int): 82 | """Value is in seconds.""" 83 | return format_duration(value) 84 | 85 | 86 | class ActionsColumn(tables.TemplateColumn): 87 | def __init__(self, template: str) -> None: 88 | super().__init__( 89 | template, 90 | attrs={"td": {"class": "text-end text-nowrap noprint"}}, 91 | verbose_name="", 92 | ) 93 | 94 | 95 | class MonospaceColumn(tables.Column): 96 | def __init__(self, *args, additional_classes: list[str] | None = None, **kwargs): 97 | cls_str = "font-monospace" 98 | if additional_classes is not None: 99 | cls_str += " " + " ".join(additional_classes) 100 | super().__init__(*args, attrs={"td": {"class": cls_str}}, **kwargs) 101 | 102 | 103 | class ServerTable(NetBoxTable): 104 | name = tables.Column(linkify=True) 105 | dhcp6 = BooleanColumn() 106 | dhcp4 = BooleanColumn() 107 | 108 | class Meta(NetBoxTable.Meta): 109 | model = Server 110 | fields = ( 111 | "pk", 112 | "name", 113 | "server_url", 114 | "username", 115 | "password", 116 | "ssl_verify", 117 | "client_cert_path", 118 | "client_key_path", 119 | "ca_file_path", 120 | "dhcp6", 121 | "dhcp4", 122 | ) 123 | default_columns = ("pk", "name", "server_url", "dhcp6", "dhcp4") 124 | 125 | 126 | # we can't use NetBox table because it requires an actual model 127 | class GenericTable(BaseTable): 128 | exempt_columns = ("actions", "pk") 129 | 130 | class Meta(BaseTable.Meta): 131 | empty_text = "No rows" 132 | fields: tuple[str, ...] = () 133 | 134 | @property 135 | def objects_count(self): 136 | return len(self.data) 137 | 138 | 139 | class SubnetTable(GenericTable): 140 | id = tables.Column(verbose_name="ID") 141 | subnet = tables.Column( 142 | linkify=lambda record, table: ( 143 | ( 144 | reverse( 145 | f"plugins:netbox_kea:server_leases{record['dhcp_version']}", 146 | args=[record["server_pk"]], 147 | ) 148 | + "?" 149 | + urlencode({"by": "subnet", "q": record["subnet"]}) 150 | ) 151 | if record.get("subnet") 152 | else None 153 | ), 154 | ) 155 | shared_network = tables.Column(verbose_name="Shared Network") 156 | actions = ActionsColumn(SUBNET_ACTIONS) 157 | 158 | class Meta(GenericTable.Meta): 159 | empty_text = "No subnets" 160 | fields = ("id", "subnet", "shared_network", "actions") 161 | default_columns = ("id", "subnet", "shared_network") 162 | 163 | 164 | class BaseLeaseTable(GenericTable): 165 | # This column is for the select checkboxes. 166 | pk = ToggleColumn(verbose_name="IP Address", accessor="ip_address", visible=True) 167 | ip_address = tables.Column(verbose_name="IP Address") 168 | hostname = tables.Column(verbose_name="Hostname") 169 | subnet_id = tables.Column(verbose_name="Subnet ID") 170 | hw_address = MonospaceColumn(verbose_name="Hardware Address") 171 | valid_lft = DurationColumn(verbose_name="Valid Lifetime") 172 | cltt = columns.DateTimeColumn(verbose_name="Client Last Transaction Time") 173 | expires_at = columns.DateTimeColumn(verbose_name="Expires At") 174 | expires_in = DurationColumn(verbose_name="Expires In") 175 | actions = ActionsColumn(LEASE_ACTIONS) 176 | 177 | class Meta(GenericTable.Meta): 178 | empty_text = "No leases found." 179 | fields = ( 180 | "ip_address", 181 | "hostname", 182 | "subnet_id", 183 | "hw_address", 184 | "valid_lft", 185 | "cltt", 186 | "expires_at", 187 | "expires_in", 188 | "actions", 189 | ) 190 | default_columns = ("ip_address", "hostname") 191 | 192 | 193 | class LeaseTable4(BaseLeaseTable): 194 | client_id = tables.Column(verbose_name="Client ID") 195 | 196 | class Meta(BaseLeaseTable.Meta): 197 | fields = ("client_id", *BaseLeaseTable.Meta.fields) 198 | 199 | 200 | class LeaseTable6(BaseLeaseTable): 201 | type = tables.Column(verbose_name="Type", accessor="type") 202 | preferred_lft = DurationColumn(verbose_name="Preferred Lifetime") 203 | duid = MonospaceColumn(verbose_name="DUID", additional_classes=["text-break"]) 204 | iaid = MonospaceColumn(verbose_name="IAID") 205 | 206 | class Meta(BaseLeaseTable.Meta): 207 | fields = ("type", "duid", "iaid", *BaseLeaseTable.Meta.fields) 208 | 209 | 210 | class LeaseDeleteTable(GenericTable): 211 | ip_address = tables.Column(verbose_name="IP Address", accessor="ip") 212 | 213 | class Meta(NetBoxTable.Meta): 214 | empty_text = "No leases" 215 | fields = ("ip_address",) 216 | default_columns = ("ip_address",) 217 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /tests/docker/kea_configs/kea-dhcp6.conf: -------------------------------------------------------------------------------- 1 | // This is a basic configuration for the Kea DHCPv6 server. Subnet declarations 2 | // are mostly commented out and no interfaces are listed. Therefore, the servers 3 | // will not listen or respond to any queries. 4 | // The basic configuration must be extended to specify interfaces on which 5 | // the servers should listen. There are a number of example options defined. 6 | // These probably don't make any sense in your network. Make sure you at least 7 | // update the following, before running this example in your network: 8 | // - change the network interface names 9 | // - change the subnets to match your actual network 10 | // - change the option values to match your network 11 | // 12 | // This is just a very basic configuration. Kea comes with large suite (over 30) 13 | // of configuration examples and extensive Kea User's Guide. Please refer to 14 | // those materials to get better understanding of what this software is able to 15 | // do. Comments in this configuration file sometimes refer to sections for more 16 | // details. These are section numbers in Kea User's Guide. The version matching 17 | // your software should come with your Kea package, but it is also available 18 | // in ISC's Knowledgebase (https://kea.readthedocs.io; the direct link for 19 | // the stable version is https://kea.readthedocs.io/). 20 | // 21 | // This configuration file contains only DHCPv6 server's configuration. 22 | // If configurations for other Kea services are also included in this file they 23 | // are ignored by the DHCPv6 server. 24 | { 25 | 26 | // DHCPv6 configuration starts here. This section will be read by DHCPv6 server 27 | // and will be ignored by other components. 28 | "Dhcp6": { 29 | // Add names of your network interfaces to listen on. 30 | "interfaces-config": { 31 | // You typically want to put specific interface names here, e.g. eth0 32 | // but you can also specify unicast addresses (e.g. eth0/2001:db8::1) if 33 | // you want your server to handle unicast traffic in addition to 34 | // multicast. (DHCPv6 is a multicast based protocol). 35 | "interfaces": [ ] 36 | }, 37 | 38 | // Kea supports control channel, which is a way to receive management commands 39 | // while the server is running. This is a Unix domain socket that receives 40 | // commands formatted in JSON, e.g. config-set (which sets new configuration), 41 | // config-reload (which tells Kea to reload its configuration from file), 42 | // statistic-get (to retrieve statistics) and many more. For detailed 43 | // description, see Sections 9.12, 16 and 15. 44 | "control-socket": { 45 | "socket-type": "unix", 46 | "socket-name": "/run/kea/kea-dhcp6-ctrl.sock" 47 | }, 48 | 49 | // Use Memfile lease database backend to store leases in a CSV file. 50 | // Depending on how Kea was compiled, it may also support SQL databases 51 | // (MySQL and/or PostgreSQL). Those database backends require more 52 | // parameters, like name, host and possibly user and password. 53 | // There are dedicated examples for each backend. See Section 8.2.2 "Lease 54 | // Storage" for details. 55 | "lease-database": { 56 | // Memfile is the simplest and easiest backend to use. It's an in-memory 57 | // C++ database that stores its state in CSV file. 58 | "type": "memfile", 59 | "lfc-interval": 3600 60 | }, 61 | 62 | // Kea allows storing host reservations in a database. If your network is 63 | // small or you have few reservations, it's probably easier to keep them 64 | // in the configuration file. If your network is large, it's usually better 65 | // to use database for it. To enable it, uncomment the following: 66 | // "hosts-database": { 67 | // "type": "mysql", 68 | // "name": "kea", 69 | // "user": "kea", 70 | // "password": "kea", 71 | // "host": "localhost", 72 | // "port": 3306 73 | // }, 74 | // See Section 8.2.3 "Hosts storage" for details. 75 | 76 | // Setup reclamation of the expired leases and leases affinity. 77 | // Expired leases will be reclaimed every 10 seconds. Every 25 78 | // seconds reclaimed leases, which have expired more than 3600 79 | // seconds ago, will be removed. The limits for leases reclamation 80 | // are 100 leases or 250 ms for a single cycle. A warning message 81 | // will be logged if there are still expired leases in the 82 | // database after 5 consecutive reclamation cycles. 83 | "expired-leases-processing": { 84 | "reclaim-timer-wait-time": 10, 85 | "flush-reclaimed-timer-wait-time": 25, 86 | "hold-reclaimed-time": 3600, 87 | "max-reclaim-leases": 100, 88 | "max-reclaim-time": 250, 89 | "unwarned-reclaim-cycles": 5 90 | }, 91 | 92 | // These parameters govern global timers. Addresses will be assigned with 93 | // preferred and valid lifetimes being 3000 and 4000, respectively. Client 94 | // is told to start renewing after 1000 seconds. If the server does not 95 | // respond after 2000 seconds since the lease was granted, a client is 96 | // supposed to start REBIND procedure (emergency renewal that allows 97 | // switching to a different server). 98 | "renew-timer": 1000, 99 | "rebind-timer": 2000, 100 | "preferred-lifetime": 3000, 101 | "valid-lifetime": 4000, 102 | 103 | // These are global options. They are going to be sent when a client requests 104 | // them, unless overwritten with values in more specific scopes. The scope 105 | // hierarchy is: 106 | // - global 107 | // - subnet 108 | // - class 109 | // - host 110 | // 111 | // Not all of those options make sense. Please configure only those that 112 | // are actually useful in your network. 113 | // 114 | // For a complete list of options currently supported by Kea, see 115 | // Section 8.2.9 "Standard DHCPv6 Options". Kea also supports 116 | // vendor options (see Section 7.2.10) and allows users to define their 117 | // own custom options (see Section 7.2.9). 118 | "option-data": [ 119 | // When specifying options, you typically need to specify 120 | // one of (name or code) and data. The full option specification 121 | // covers name, code, space, csv-format and data. 122 | // space defaults to "dhcp6" which is usually correct, unless you 123 | // use encapsulate options. csv-format defaults to "true", so 124 | // this is also correct, unless you want to specify the whole 125 | // option value as long hex string. For example, to specify 126 | // domain-name-servers you could do this: 127 | // { 128 | // "name": "dns-servers", 129 | // "code": 23, 130 | // "csv-format": "true", 131 | // "space": "dhcp6", 132 | // "data": "2001:db8:2::45, 2001:db8:2::100" 133 | // } 134 | // but it's a lot of writing, so it's easier to do this instead: 135 | { 136 | "name": "dns-servers", 137 | "data": "2001:db8:2::45, 2001:db8:2::100" 138 | }, 139 | 140 | // Typically people prefer to refer to options by their names, so they 141 | // don't need to remember the code names. However, some people like 142 | // to use numerical values. For example, DHCPv6 can optionally use 143 | // server unicast communication, if extra option is present. Option 144 | // "unicast" uses option code 12, so you can reference to it either 145 | // by "name": "unicast" or "code": 12. If you enable this option, 146 | // you really should also tell the server to listen on that address 147 | // (see interfaces-config/interfaces list above). 148 | { 149 | "code": 12, 150 | "data": "2001:db8::1" 151 | }, 152 | 153 | // String options that have a comma in their values need to have 154 | // it escaped (i.e. each comma is preceded by two backslashes). 155 | // That's because commas are reserved for separating fields in 156 | // compound options. At the same time, we need to be conformant 157 | // with JSON spec, that does not allow "\,". Therefore the 158 | // slightly uncommon double backslashes notation is needed. 159 | 160 | // Legal JSON escapes are \ followed by "\/bfnrt character 161 | // or \u followed by 4 hexadecimal numbers (currently Kea 162 | // supports only \u0000 to \u00ff code points). 163 | // CSV processing translates '\\' into '\' and '\,' into ',' 164 | // only so for instance '\x' is translated into '\x'. But 165 | // as it works on a JSON string value each of these '\' 166 | // characters must be doubled on JSON input. 167 | { 168 | "name": "new-posix-timezone", 169 | "data": "EST5EDT4\\,M3.2.0/02:00\\,M11.1.0/02:00" 170 | }, 171 | 172 | // Options that take integer values can either be specified in 173 | // dec or hex format. Hex format could be either plain (e.g. abcd) 174 | // or prefixed with 0x (e.g. 0xabcd). 175 | { 176 | "name": "preference", 177 | "data": "0xf0" 178 | }, 179 | 180 | // A few options are encoded in (length, string) tuples 181 | // which can be defined using only strings as the CSV 182 | // processing computes lengths. 183 | { 184 | "name": "bootfile-param", 185 | "data": "root=/dev/sda2, quiet, splash" 186 | } 187 | ], 188 | 189 | // Another thing possible here are hooks. Kea supports a powerful mechanism 190 | // that allows loading external libraries that can extract information and 191 | // even influence how the server processes packets. Those libraries include 192 | // additional forensic logging capabilities, ability to reserve hosts in 193 | // more flexible ways, and even add extra commands. For a list of available 194 | // hook libraries, see https://gitlab.isc.org/isc-projects/kea/wikis/Hooks-available. 195 | "hooks-libraries": [ 196 | {"library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"}, 197 | {"library": "/usr/lib/kea/hooks/libdhcp_stat_cmds.so"} 198 | ], 199 | // { 200 | // // Forensic Logging library generates forensic type of audit trail 201 | // // of all devices serviced by Kea, including their identifiers 202 | // // (like MAC address), their location in the network, times 203 | // // when they were active etc. 204 | // "library": "/usr/lib/kea/hooks/libdhcp_legal_log.so", 205 | // "parameters": { 206 | // "path": "/var/lib/kea", 207 | // "base-name": "kea-forensic6" 208 | // } 209 | // }, 210 | // { 211 | // // Flexible identifier (flex-id). Kea software provides a way to 212 | // // handle host reservations that include addresses, prefixes, 213 | // // options, client classes and other features. The reservation can 214 | // // be based on hardware address, DUID, circuit-id or client-id in 215 | // // DHCPv4 and using hardware address or DUID in DHCPv6. However, 216 | // // there are sometimes scenario where the reservation is more 217 | // // complex, e.g. uses other options that mentioned above, uses part 218 | // // of specific options or perhaps even a combination of several 219 | // // options and fields to uniquely identify a client. Those scenarios 220 | // // are addressed by the Flexible Identifiers hook application. 221 | // "library": "/usr/lib/kea/hooks/libdhcp_flex_id.so", 222 | // "parameters": { 223 | // "identifier-expression": "relay6[0].option[37].hex" 224 | // } 225 | // } 226 | // ], 227 | 228 | // Below an example of a simple IPv6 subnet declaration. Uncomment to enable 229 | // it. This is a list, denoted with [ ], of structures, each denoted with 230 | // { }. Each structure describes a single subnet and may have several 231 | // parameters. One of those parameters is "pools" that is also a list of 232 | // structures. 233 | "subnet6": [ 234 | { 235 | // This defines the whole subnet. Kea will use this information to 236 | // determine where the clients are connected. This is the whole 237 | // subnet in your network. This is mandatory parameter for each 238 | // subnet. 239 | "subnet": "2001:db8:1::/64", 240 | "id": 1, 241 | 242 | // Pools define the actual part of your subnet that is governed 243 | // by Kea. Technically this is optional parameter, but it's 244 | // almost always needed for DHCP to do its job. If you omit it, 245 | // clients won't be able to get addresses, unless there are 246 | // host reservations defined for them. 247 | "pools": [ { "pool": "2001:db8:1::/80" } ], 248 | 249 | // Kea supports prefix delegation (PD). This mechanism delegates 250 | // whole prefixes, instead of single addresses. You need to specify 251 | // a prefix and then size of the delegated prefixes that it will 252 | // be split into. This example below tells Kea to use 253 | // 2001:db8:1::/56 prefix as pool and split it into /64 prefixes. 254 | // This will give you 256 (2^(64-56)) prefixes. 255 | "pd-pools": [ 256 | { 257 | "prefix": "2001:db8:8::", 258 | "prefix-len": 56, 259 | "delegated-len": 64 260 | 261 | // Kea also supports excluded prefixes. This advanced option 262 | // is explained in Section 9.2.9. Please make sure your 263 | // excluded prefix matches the pool it is defined in. 264 | // "excluded-prefix": "2001:db8:8:0:80::", 265 | // "excluded-prefix-len": 72 266 | } 267 | ], 268 | "option-data": [ 269 | // You can specify additional options here that are subnet 270 | // specific. Also, you can override global options here. 271 | { 272 | "name": "dns-servers", 273 | "data": "2001:db8:2::dead:beef, 2001:db8:2::cafe:babe" 274 | } 275 | ], 276 | 277 | // Host reservations can be defined for each subnet. 278 | // 279 | // Note that reservations are subnet-specific in Kea. This is 280 | // different than ISC DHCP. Keep that in mind when migrating 281 | // your configurations. 282 | "reservations": [ 283 | // This is a simple host reservation. The host with DUID matching 284 | // the specified value will get an address of 2001:db8:1::100. 285 | { 286 | "duid": "01:02:03:04:05:0A:0B:0C:0D:0E", 287 | "ip-addresses": [ "2001:db8:1::100" ] 288 | }, 289 | 290 | // This is similar to the previous one, but this time the 291 | // reservation is done based on hardware/MAC address. The server 292 | // will do its best to extract the hardware/MAC address from 293 | // received packets (see 'mac-sources' directive for 294 | // details). This particular reservation also specifies two 295 | // extra options to be available for this client. If there are 296 | // options with the same code specified in a global, subnet or 297 | // class scope, the values defined at host level take 298 | // precedence. 299 | { 300 | "hw-address": "00:01:02:03:04:05", 301 | "ip-addresses": [ "2001:db8:1::101" ], 302 | "option-data": [ 303 | { 304 | "name": "dns-servers", 305 | "data": "3000:1::234" 306 | }, 307 | { 308 | "name": "nis-servers", 309 | "data": "3000:1::234" 310 | }], 311 | 312 | // This client will be automatically added to certain 313 | // classes. 314 | "client-classes": [ "special_snowflake", "office" ] 315 | }, 316 | 317 | // This is a bit more advanced reservation. The client with the 318 | // specified DUID will get a reserved address, a reserved prefix 319 | // and a hostname. This reservation is for an address that it 320 | // not within the dynamic pool. Finally, this reservation 321 | // features vendor specific options for CableLabs, which happen 322 | // to use enterprise-id 4491. Those particular values will be 323 | // returned only to the client that has a DUID matching this 324 | // reservation. 325 | { 326 | "duid": "01:02:03:04:05:06:07:08:09:0A", 327 | "ip-addresses": [ "2001:db8:1:0:cafe::1" ], 328 | "prefixes": [ "2001:db8:2:abcd::/64" ], 329 | "hostname": "foo.example.com", 330 | "option-data": [ 331 | { 332 | "name": "vendor-opts", 333 | "data": "4491" 334 | }, 335 | { 336 | "name": "tftp-servers", 337 | "space": "vendor-4491", 338 | "data": "3000:1::234" 339 | } 340 | ] 341 | }, 342 | 343 | // This reservation is using flexible identifier. Instead of 344 | // relying on specific field, sysadmin can define an expression 345 | // similar to what is used for client classification, 346 | // e.g. substring(relay[0].option[17],0,6). Then, based on the 347 | // value of that expression for incoming packet, the reservation 348 | // is matched. Expression can be specified either as hex or 349 | // plain text using single quotes. 350 | 351 | // Note: flexible identifier requires flex_id hook library to be 352 | // loaded to work. 353 | { 354 | "flex-id": "'somevalue'", 355 | "ip-addresses": [ "2001:db8:1:0:cafe::2" ] 356 | } 357 | ] 358 | } 359 | // More subnets can be defined here. 360 | // { 361 | // "subnet": "2001:db8:2::/64", 362 | // "pools": [ { "pool": "2001:db8:2::/80" } ] 363 | // }, 364 | // { 365 | // "subnet": "2001:db8:3::/64", 366 | // "pools": [ { "pool": "2001:db8:3::/80" } ] 367 | // }, 368 | // { 369 | // "subnet": "2001:db8:4::/64", 370 | // "pools": [ { "pool": "2001:db8:4::/80" } ] 371 | // } 372 | ], 373 | 374 | "shared-networks": [ 375 | { 376 | "name": "test-shared-network-6", 377 | "subnet6": [ 378 | { 379 | "id": 2, 380 | "subnet": "2001:db8:2::/64", 381 | "pools": [ { "pool": "2001:db8:2::/64" } ] 382 | } 383 | ] 384 | } 385 | ], 386 | 387 | // Client-classes can be defined here. See "client-classes" in Dhcp4 for 388 | // an example. 389 | 390 | // DDNS information (how the DHCPv6 component can reach a DDNS daemon) 391 | 392 | // Logging configuration starts here. Kea uses different loggers to log various 393 | // activities. For details (e.g. names of loggers), see Chapter 18. 394 | "loggers": [ 395 | { 396 | // This specifies the logging for kea-dhcp6 logger, i.e. all logs 397 | // generated by Kea DHCPv6 server. 398 | "name": "kea-dhcp6", 399 | "output_options": [ 400 | { 401 | // Specifies the output file. There are several special values 402 | // supported: 403 | // - stdout (prints on standard output) 404 | // - stderr (prints on standard error) 405 | // - syslog (logs to syslog) 406 | // - syslog:name (logs to syslog using specified name) 407 | // Any other value is considered a name of the file 408 | "output": "stdout", 409 | 410 | // Shorter log pattern suitable for use with systemd, 411 | // avoids redundant information 412 | // "pattern": "%-5p %m\n", 413 | 414 | // This governs whether the log output is flushed to disk after 415 | // every write. 416 | // "flush": false, 417 | 418 | // This specifies the maximum size of the file before it is 419 | // rotated. 420 | // "maxsize": 1048576, 421 | 422 | // This specifies the maximum number of rotated files to keep. 423 | // "maxver": 8 424 | } 425 | ], 426 | // This specifies the severity of log messages to keep. Supported values 427 | // are: FATAL, ERROR, WARN, INFO, DEBUG 428 | "severity": "INFO", 429 | 430 | // If DEBUG level is specified, this value is used. 0 is least verbose, 431 | // 99 is most verbose. Be cautious, Kea can generate lots and lots 432 | // of logs if told to do so. 433 | "debuglevel": 0 434 | } 435 | ] 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /netbox_kea/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABCMeta 3 | from typing import Any, Generic, TypeVar 4 | 5 | from django.contrib import messages 6 | from django.http import HttpResponse, HttpResponseForbidden 7 | from django.http.request import HttpRequest 8 | from django.shortcuts import redirect, render 9 | from django.urls import reverse 10 | from netaddr import IPAddress, IPNetwork 11 | from netbox.tables import BaseTable 12 | from netbox.views import generic 13 | from utilities.exceptions import AbortRequest 14 | from utilities.htmx import htmx_partial 15 | from utilities.paginator import EnhancedPaginator, get_paginate_count 16 | from utilities.views import GetReturnURLMixin, ViewTab, register_model_view 17 | 18 | from . import constants, forms, tables 19 | from .filtersets import ServerFilterSet 20 | from .kea import KeaClient 21 | from .models import Server 22 | from .utilities import ( 23 | OptionalViewTab, 24 | check_dhcp_enabled, 25 | export_table, 26 | format_duration, 27 | format_leases, 28 | ) 29 | 30 | T = TypeVar("T", bound=BaseTable) 31 | 32 | 33 | @register_model_view(Server) 34 | class ServerView(generic.ObjectView): 35 | queryset = Server.objects.all() 36 | 37 | 38 | @register_model_view(Server, "edit") 39 | class ServerEditView(generic.ObjectEditView): 40 | queryset = Server.objects.all() 41 | form = forms.ServerForm 42 | 43 | 44 | @register_model_view(Server, "delete") 45 | class ServerDeleteView(generic.ObjectDeleteView): 46 | queryset = Server.objects.all() 47 | 48 | 49 | class ServerListView(generic.ObjectListView): 50 | queryset = Server.objects.all() 51 | table = tables.ServerTable 52 | filterset = ServerFilterSet 53 | filterset_form = forms.ServerFilterForm 54 | 55 | 56 | class ServerBulkDeleteView(generic.BulkDeleteView): 57 | queryset = Server.objects.all() 58 | table = tables.ServerTable 59 | 60 | 61 | @register_model_view(Server, "status") 62 | class ServerStatusView(generic.ObjectView): 63 | queryset = Server.objects.all() 64 | tab = ViewTab(label="Status", weight=1000) 65 | template_name = "netbox_kea/server_status.html" 66 | 67 | def _get_ca_status(self, client: KeaClient) -> dict[str, Any]: 68 | """Get the control agent status""" 69 | status = client.command("status-get") 70 | args = status[0]["arguments"] 71 | assert args is not None 72 | 73 | version = client.command("version-get") 74 | version_args = version[0]["arguments"] 75 | assert version_args is not None 76 | 77 | return { 78 | "PID": args["pid"], 79 | "Uptime": format_duration(int(args["uptime"])), 80 | "Time since reload": format_duration(int(args["reload"])), 81 | "Version": version_args["extended"], 82 | } 83 | 84 | def _get_dhcp_status( 85 | self, server: Server, client: KeaClient 86 | ) -> dict[str, dict[str, Any]]: 87 | resp: dict[str, dict[str, Any]] = {} 88 | 89 | # Map of name to pretty name 90 | service_names = {"dhcp6": "DHCPv6", "dhcp4": "DHCPv4"} 91 | services = [] 92 | if server.dhcp6: 93 | services.append("dhcp6") 94 | if server.dhcp4: 95 | services.append("dhcp4") 96 | service_keys = list(services) 97 | 98 | dhcp_status = client.command("status-get", service=service_keys) 99 | dhcp_version = client.command("version-get", service=service_keys) 100 | assert len(dhcp_status) == len(services) 101 | assert len(dhcp_version) == len(services) 102 | for svc, status, version in zip(services, dhcp_status, dhcp_version): 103 | args = status["arguments"] 104 | assert args is not None 105 | 106 | version_args = version["arguments"] 107 | assert version_args is not None 108 | 109 | resp[service_names[svc]] = { 110 | "PID": args["pid"], 111 | "Uptime": format_duration(args["uptime"]), 112 | "Time since reload": format_duration(int(args["reload"])), 113 | "Version": version_args["extended"], 114 | } 115 | 116 | if (ha := args.get("high-availability")) is not None: 117 | # https://kea.readthedocs.io/en/latest/arm/hooks.html#load-balancing-configuration 118 | # Note that while the top-level parameter high-availability is a list, 119 | # only a single entry is currently supported. 120 | 121 | ha_servers = ha[0].get("ha-servers") 122 | ha_local = ha_servers.get("local", {}) 123 | ha_remote = ha_servers.get("remote", {}) 124 | resp[service_names[svc]].update( 125 | { 126 | "HA mode": ha[0].get("ha-mode"), 127 | "HA local role": ha_local.get("role"), 128 | "HA local state": ha_local.get("state"), 129 | "HA remote connection interrupted": str( 130 | ha_remote.get("connection-interrupted") 131 | ), 132 | "HA remote age (seconds)": ha_remote.get("age"), 133 | "HA remote role": ha_remote.get("role"), 134 | "HA remote last state": ha_remote.get("last-state"), 135 | "HA remote in touch": ha_remote.get("in-touch"), 136 | "HA remote unacked clients": ha_remote.get("unacked-clients"), 137 | "HA remote unacked clients left": ha_remote.get( 138 | "unacked-clients-left" 139 | ), 140 | "HA remote connecting clients": ha_remote.get( 141 | "connecting-clients" 142 | ), 143 | } 144 | ) 145 | return resp 146 | 147 | def _get_statuses( 148 | self, server: Server, client: KeaClient 149 | ) -> dict[str, dict[str, Any]]: 150 | return { 151 | "Control Agent": self._get_ca_status(client), 152 | **self._get_dhcp_status(server, client), 153 | } 154 | 155 | def get_extra_context( 156 | self, request: HttpResponse, instance: Server 157 | ) -> dict[str, Any]: 158 | return {"statuses": self._get_statuses(instance, instance.get_client())} 159 | 160 | 161 | class BaseServerLeasesView(generic.ObjectView, Generic[T]): 162 | template_name = "netbox_kea/server_dhcp_leases.html" 163 | queryset = Server.objects.all() 164 | table: type[T] 165 | 166 | def get_table(self, data: list[dict[str, Any]], request: HttpRequest) -> T: 167 | table = self.table(data, user=request.user) 168 | table.configure(request) 169 | return table 170 | 171 | def get_leases_page( 172 | self, client: KeaClient, subnet: IPNetwork, page: str | None, per_page: int 173 | ) -> tuple[list[dict[str, Any]], str | None]: 174 | if page: 175 | frm = page 176 | elif int(subnet.network) == 0: 177 | frm = str(subnet.network) 178 | else: 179 | frm = str(subnet.network - 1) 180 | 181 | resp = client.command( 182 | f"lease{self.dhcp_version}-get-page", 183 | service=[f"dhcp{self.dhcp_version}"], 184 | arguments={"from": frm, "limit": per_page}, 185 | check=(0, 3), 186 | ) 187 | 188 | if resp[0]["result"] == 3: 189 | return [], None 190 | 191 | args = resp[0]["arguments"] 192 | assert args is not None 193 | 194 | raw_leases = args["leases"] 195 | next = f"{raw_leases[-1]['ip-address']}" if args["count"] == per_page else None 196 | for i, lease in enumerate(raw_leases): 197 | lease_ip = IPAddress(lease["ip-address"]) 198 | if lease_ip not in subnet: 199 | raw_leases = raw_leases[:i] 200 | next = None 201 | break 202 | 203 | subnet_leases = format_leases(raw_leases) 204 | 205 | return subnet_leases, next 206 | 207 | def get_leases(self, client: KeaClient, q: Any, by: str) -> list[dict[str, Any]]: 208 | arguments: dict[str, Any] 209 | command = "" 210 | multiple = True 211 | 212 | if by == constants.BY_IP: 213 | arguments = {"ip-address": q} 214 | multiple = False 215 | elif by == constants.BY_HW_ADDRESS: 216 | arguments = {"hw-address": q} 217 | command = "-by-hw-address" 218 | elif by == constants.BY_HOSTNAME: 219 | arguments = {"hostname": q} 220 | command = "-by-hostname" 221 | elif by == constants.BY_CLIENT_ID: 222 | arguments = {"client-id": q} 223 | command = "-by-client-id" 224 | elif by == constants.BY_SUBNET_ID: 225 | command = "-all" 226 | arguments = {"subnets": [int(q)]} 227 | elif by == constants.BY_DUID: 228 | command = "-by-duid" 229 | arguments = {"duid": q} 230 | else: 231 | # We should never get here because the 232 | # form should of been validated. 233 | raise AbortRequest(f"Invalid search by (this shouldn't happen): {by}") 234 | resp = client.command( 235 | f"lease{self.dhcp_version}-get{command}", 236 | service=[f"dhcp{self.dhcp_version}"], 237 | arguments=arguments, 238 | check=(0, 3), 239 | ) 240 | 241 | if resp[0]["result"] == 3: 242 | return [] 243 | 244 | args = resp[0]["arguments"] 245 | assert args is not None 246 | if multiple is True: 247 | return format_leases(args["leases"]) 248 | return format_leases([args]) 249 | 250 | def get_extra_context( 251 | self, request: HttpRequest, _instance: Server 252 | ) -> dict[str, Any]: 253 | # For non-htmx requests. 254 | 255 | table = self.get_table([], request) 256 | form = self.form(request.GET) if "q" in request.GET else self.form() 257 | return {"form": form, "table": table} 258 | 259 | def get_export(self, request: HttpRequest, **kwargs) -> HttpResponse: 260 | form = self.form(request.GET) 261 | if not form.is_valid(): 262 | messages.warning(request, "Invalid form for export.") 263 | return redirect(request.path) 264 | 265 | instance = self.get_object(**kwargs) 266 | 267 | by = form.cleaned_data["by"] 268 | q = form.cleaned_data["q"] 269 | client = instance.get_client() 270 | if by == constants.BY_SUBNET: 271 | leases = [] 272 | page: str | None = "" # start from the beginning 273 | while page is not None: 274 | page_leases, page = self.get_leases_page( 275 | client, 276 | q, 277 | page, 278 | per_page=get_paginate_count(request), 279 | ) 280 | leases += page_leases 281 | else: 282 | leases = self.get_leases(client, q, by) 283 | 284 | table = self.get_table(leases, request) 285 | return export_table( 286 | table, "leases.csv", use_selected_columns=request.GET["export"] == "table" 287 | ) 288 | 289 | def get(self, request: HttpRequest, **kwargs) -> HttpResponse: 290 | logger = logging.getLogger("netbox_kea.views.BaseServerDHCPLeasesView") 291 | 292 | instance: Server = self.get_object(**kwargs) 293 | 294 | if resp := check_dhcp_enabled(instance, self.dhcp_version): 295 | return resp 296 | 297 | if "export" in request.GET: 298 | return self.get_export(request, **kwargs) 299 | 300 | if not request.htmx: 301 | return super().get(request, **kwargs) 302 | 303 | try: 304 | form = self.form(request.GET) 305 | if not form.is_valid(): 306 | table = self.get_table([], request) 307 | return render( 308 | request, 309 | "netbox_kea/server_dhcp_leases_htmx.html", 310 | { 311 | "is_embedded": False, 312 | "form": form, 313 | "table": table, 314 | "paginate": False, 315 | }, 316 | ) 317 | 318 | by = form.cleaned_data["by"] 319 | q = form.cleaned_data["q"] 320 | client = instance.get_client() 321 | if by == "subnet": 322 | leases, next_page = self.get_leases_page( 323 | client, 324 | q, 325 | form.cleaned_data["page"], 326 | per_page=get_paginate_count(request), 327 | ) 328 | paginate = True 329 | else: 330 | paginate = False 331 | next_page = None 332 | leases = self.get_leases(client, q, by) 333 | 334 | table = self.get_table(leases, request) 335 | 336 | can_delete = request.user.has_perm( 337 | "netbox_kea.bulk_delete_lease_from_server", 338 | obj=instance, 339 | ) 340 | if not can_delete: 341 | table.columns.hide("pk") 342 | 343 | return render( 344 | request, 345 | "netbox_kea/server_dhcp_leases_htmx.html", 346 | { 347 | "can_delete": can_delete, 348 | "is_embedded": False, 349 | "delete_action": reverse( 350 | f"plugins:netbox_kea:server_leases{self.dhcp_version}_delete", 351 | args=[instance.pk], 352 | ), 353 | "form": form, 354 | "table": table, 355 | "next_page": next_page, 356 | "paginate": paginate, 357 | "page_lengths": EnhancedPaginator.default_page_lengths, 358 | }, 359 | ) 360 | except Exception as e: 361 | logger.exception("exception on DHCP leases HTMX handler") 362 | return render( 363 | request, 364 | "netbox_kea/exception_htmx.html", 365 | {"type_": type(e).__name__, "exception": str(e)}, 366 | ) 367 | 368 | 369 | @register_model_view(Server, "leases6") 370 | class ServerLeases6View(BaseServerLeasesView[tables.LeaseTable6]): 371 | tab = OptionalViewTab( 372 | label="DHCPv6 Leases", weight=1010, is_enabled=lambda s: s.dhcp6 373 | ) 374 | form = forms.Leases6SearchForm 375 | table = tables.LeaseTable6 376 | dhcp_version = 6 377 | 378 | 379 | @register_model_view(Server, "leases4") 380 | class ServerLeases4View(BaseServerLeasesView[tables.LeaseTable4]): 381 | tab = OptionalViewTab( 382 | label="DHCPv4 Leases", weight=1020, is_enabled=lambda s: s.dhcp4 383 | ) 384 | form = forms.Leases4SearchForm 385 | table = tables.LeaseTable4 386 | dhcp_version = 4 387 | 388 | 389 | class FakeLeaseModelMeta: 390 | verbose_name_plural = "leases" 391 | 392 | 393 | # Fake model to allow us to use the bulk_delete.html template. 394 | class FakeLeaseModel: 395 | _meta = FakeLeaseModelMeta 396 | 397 | 398 | class BaseServerLeasesDeleteView( 399 | GetReturnURLMixin, generic.ObjectView, metaclass=ABCMeta 400 | ): 401 | queryset = Server.objects.all() 402 | default_return_url = "plugins:netbox_kea:server_list" 403 | 404 | def delete_lease(self, client: KeaClient, ip: str) -> None: 405 | client.command( 406 | f"lease{self.dhcp_version}-del", 407 | arguments={"ip-address": ip}, 408 | service=[f"dhcp{self.dhcp_version}"], 409 | check=(0, 3), 410 | ) 411 | 412 | def get(self, request: HttpRequest, **kwargs): 413 | return redirect(self.get_return_url(request, obj=self.get_object(**kwargs))) 414 | 415 | def post(self, request: HttpRequest, **kwargs) -> HttpResponse: 416 | instance: Server = self.get_object(**kwargs) 417 | 418 | if not request.user.has_perm( 419 | "netbox_kea.bulk_delete_lease_from_server", obj=instance 420 | ): 421 | return HttpResponseForbidden( 422 | "This user does not have permission to delete DHCP leases." 423 | ) 424 | 425 | form = self.form(request.POST) 426 | 427 | if not form.is_valid(): 428 | messages.warning(request, str(form.errors)) 429 | return redirect(self.get_return_url(request, obj=instance)) 430 | 431 | lease_ips = form.cleaned_data["pk"] 432 | if "_confirm" not in request.POST: 433 | return render( 434 | request, 435 | "generic/bulk_delete.html", 436 | { 437 | "model": FakeLeaseModel, 438 | "table": tables.LeaseDeleteTable( 439 | ({"ip": ip} for ip in lease_ips), 440 | orderable=False, 441 | ), 442 | "form": form, 443 | "return_url": self.get_return_url(request, obj=instance), 444 | }, 445 | ) 446 | 447 | client = instance.get_client() 448 | 449 | for ip in lease_ips: 450 | try: 451 | self.delete_lease(client, ip) 452 | except Exception as e: # noqa: PERF203 453 | messages.error(request, f"Error deleting lease {ip}: {repr(e)}") 454 | return redirect(self.get_return_url(request, obj=instance)) 455 | 456 | messages.success( 457 | request, f"Deleted {len(lease_ips)} DHCPv{self.dhcp_version} lease(s)." 458 | ) 459 | return redirect(self.get_return_url(request, obj=instance)) 460 | 461 | 462 | class ServerLeases6DeleteView(BaseServerLeasesDeleteView): 463 | form = forms.Lease6DeleteForm 464 | dhcp_version = 6 465 | 466 | 467 | class ServerLeases4DeleteView(BaseServerLeasesDeleteView): 468 | form = forms.Lease4DeleteForm 469 | dhcp_version = 4 470 | 471 | 472 | class BaseServerDHCPSubnetsView(generic.ObjectChildrenView): 473 | table = tables.SubnetTable 474 | queryset = Server.objects.all() 475 | template_name = "netbox_kea/server_dhcp_subnets.html" 476 | 477 | def get_children( 478 | self, request: HttpRequest, parent: Server 479 | ) -> list[dict[str, Any]]: 480 | return self.get_subnets(parent) 481 | 482 | def get_subnets(self, server: Server) -> list[dict[str, Any]]: 483 | client = server.get_client() 484 | config = client.command("config-get", service=[f"dhcp{self.dhcp_version}"]) 485 | assert config[0]["arguments"] is not None 486 | subnets = config[0]["arguments"][f"Dhcp{self.dhcp_version}"][ 487 | f"subnet{self.dhcp_version}" 488 | ] 489 | subnet_list = [ 490 | { 491 | "id": s["id"], 492 | "subnet": s["subnet"], 493 | "dhcp_version": self.dhcp_version, 494 | "server_pk": server.pk, 495 | } 496 | for s in subnets 497 | if "id" in s and "subnet" in s 498 | ] 499 | 500 | for sn in config[0]["arguments"][f"Dhcp{self.dhcp_version}"]["shared-networks"]: 501 | subnet_list.extend( 502 | { 503 | "id": s["id"], 504 | "subnet": s["subnet"], 505 | "shared_network": sn["name"], 506 | "dhcp_version": self.dhcp_version, 507 | "server_pk": server.pk, 508 | } 509 | for s in sn[f"subnet{self.dhcp_version}"] 510 | ) 511 | 512 | return subnet_list 513 | 514 | def get(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: 515 | instance = self.get_object(**kwargs) 516 | if resp := check_dhcp_enabled(instance, self.dhcp_version): 517 | return resp 518 | 519 | # We can't use the original get() since it calls get_table_configs which requires a NetBox model. 520 | instance = self.get_object(**kwargs) 521 | child_objects = self.get_children(request, instance) 522 | 523 | table_data = self.prep_table_data(request, child_objects, instance) 524 | table = self.get_table(table_data, request, False) 525 | 526 | if "export" in request.GET: 527 | return export_table( 528 | table, 529 | filename=f"kea-dhcpv{self.dhcp_version}-subnets.csv", 530 | use_selected_columns=request.GET["export"] == "table", 531 | ) 532 | 533 | # If this is an HTMX request, return only the rendered table HTML 534 | if htmx_partial(request): 535 | return render( 536 | request, 537 | "htmx/table.html", 538 | { 539 | "object": instance, 540 | "table": table, 541 | "model": self.child_model, 542 | }, 543 | ) 544 | 545 | return render( 546 | request, 547 | self.get_template_name(), 548 | { 549 | "object": instance, 550 | "base_template": f"{instance._meta.app_label}/{instance._meta.model_name}.html", 551 | "table": table, 552 | "table_config": f"{table.name}_config", 553 | "return_url": request.get_full_path(), 554 | }, 555 | ) 556 | 557 | 558 | @register_model_view(Server, "subnets6") 559 | class ServerDHCP6SubnetsView(BaseServerDHCPSubnetsView): 560 | tab = OptionalViewTab( 561 | label="DHCPv6 Subnets", weight=1030, is_enabled=lambda s: s.dhcp6 562 | ) 563 | dhcp_version = 6 564 | 565 | 566 | @register_model_view(Server, "subnets4") 567 | class ServerDHCP4SubnetsView(BaseServerDHCPSubnetsView): 568 | tab = OptionalViewTab( 569 | label="DHCPv4 Subnets", weight=1040, is_enabled=lambda s: s.dhcp4 570 | ) 571 | dhcp_version = 4 572 | -------------------------------------------------------------------------------- /tests/docker/kea_configs/kea-dhcp4.conf: -------------------------------------------------------------------------------- 1 | // This is a basic configuration for the Kea DHCPv4 server. Subnet declarations 2 | // are mostly commented out and no interfaces are listed. Therefore, the servers 3 | // will not listen or respond to any queries. 4 | // The basic configuration must be extended to specify interfaces on which 5 | // the servers should listen. There are a number of example options defined. 6 | // These probably don't make any sense in your network. Make sure you at least 7 | // update the following, before running this example in your network: 8 | // - change the network interface names 9 | // - change the subnets to match your actual network 10 | // - change the option values to match your network 11 | // 12 | // This is just a very basic configuration. Kea comes with large suite (over 30) 13 | // of configuration examples and extensive Kea User's Guide. Please refer to 14 | // those materials to get better understanding of what this software is able to 15 | // do. Comments in this configuration file sometimes refer to sections for more 16 | // details. These are section numbers in Kea User's Guide. The version matching 17 | // your software should come with your Kea package, but it is also available 18 | // in ISC's Knowledgebase (https://kea.readthedocs.io; the direct link for 19 | // the stable version is https://kea.readthedocs.io/). 20 | // 21 | // This configuration file contains only DHCPv4 server's configuration. 22 | // If configurations for other Kea services are also included in this file they 23 | // are ignored by the DHCPv4 server. 24 | { 25 | 26 | // DHCPv4 configuration starts here. This section will be read by DHCPv4 server 27 | // and will be ignored by other components. 28 | "Dhcp4": { 29 | // Add names of your network interfaces to listen on. 30 | "interfaces-config": { 31 | // See section 8.2.4 for more details. You probably want to add just 32 | // interface name (e.g. "eth0" or specific IPv4 address on that 33 | // interface name (e.g. "eth0/192.0.2.1"). 34 | "interfaces": [ ] 35 | 36 | // Kea DHCPv4 server by default listens using raw sockets. This ensures 37 | // all packets, including those sent by directly connected clients 38 | // that don't have IPv4 address yet, are received. However, if your 39 | // traffic is always relayed, it is often better to use regular 40 | // UDP sockets. If you want to do that, uncomment this line: 41 | // "dhcp-socket-type": "udp" 42 | }, 43 | 44 | // Kea supports control channel, which is a way to receive management 45 | // commands while the server is running. This is a Unix domain socket that 46 | // receives commands formatted in JSON, e.g. config-set (which sets new 47 | // configuration), config-reload (which tells Kea to reload its 48 | // configuration from file), statistic-get (to retrieve statistics) and many 49 | // more. For detailed description, see Sections 8.8, 16 and 15. 50 | "control-socket": { 51 | "socket-type": "unix", 52 | "socket-name": "/run/kea/kea-dhcp4-ctrl.sock" 53 | }, 54 | 55 | // Use Memfile lease database backend to store leases in a CSV file. 56 | // Depending on how Kea was compiled, it may also support SQL databases 57 | // (MySQL and/or PostgreSQL). Those database backends require more 58 | // parameters, like name, host and possibly user and password. 59 | // There are dedicated examples for each backend. See Section 7.2.2 "Lease 60 | // Storage" for details. 61 | "lease-database": { 62 | // Memfile is the simplest and easiest backend to use. It's an in-memory 63 | // C++ database that stores its state in CSV file. 64 | "type": "memfile", 65 | "lfc-interval": 3600 66 | }, 67 | 68 | // Kea allows storing host reservations in a database. If your network is 69 | // small or you have few reservations, it's probably easier to keep them 70 | // in the configuration file. If your network is large, it's usually better 71 | // to use database for it. To enable it, uncomment the following: 72 | // "hosts-database": { 73 | // "type": "mysql", 74 | // "name": "kea", 75 | // "user": "kea", 76 | // "password": "kea", 77 | // "host": "localhost", 78 | // "port": 3306 79 | // }, 80 | // See Section 7.2.3 "Hosts storage" for details. 81 | 82 | // Setup reclamation of the expired leases and leases affinity. 83 | // Expired leases will be reclaimed every 10 seconds. Every 25 84 | // seconds reclaimed leases, which have expired more than 3600 85 | // seconds ago, will be removed. The limits for leases reclamation 86 | // are 100 leases or 250 ms for a single cycle. A warning message 87 | // will be logged if there are still expired leases in the 88 | // database after 5 consecutive reclamation cycles. 89 | "expired-leases-processing": { 90 | "reclaim-timer-wait-time": 10, 91 | "flush-reclaimed-timer-wait-time": 25, 92 | "hold-reclaimed-time": 3600, 93 | "max-reclaim-leases": 100, 94 | "max-reclaim-time": 250, 95 | "unwarned-reclaim-cycles": 5 96 | }, 97 | 98 | // Global timers specified here apply to all subnets, unless there are 99 | // subnet specific values defined in particular subnets. 100 | "renew-timer": 900, 101 | "rebind-timer": 1800, 102 | "valid-lifetime": 3600, 103 | 104 | // Many additional parameters can be specified here: 105 | // - option definitions (if you want to define vendor options, your own 106 | // custom options or perhaps handle standard options 107 | // that Kea does not support out of the box yet) 108 | // - client classes 109 | // - hooks 110 | // - ddns information (how the DHCPv4 component can reach a DDNS daemon) 111 | // 112 | // Some of them have examples below, but there are other parameters. 113 | // Consult Kea User's Guide to find out about them. 114 | 115 | // These are global options. They are going to be sent when a client 116 | // requests them, unless overwritten with values in more specific scopes. 117 | // The scope hierarchy is: 118 | // - global (most generic, can be overwritten by class, subnet or host) 119 | // - class (can be overwritten by subnet or host) 120 | // - subnet (can be overwritten by host) 121 | // - host (most specific, overwrites any other scopes) 122 | // 123 | // Not all of those options make sense. Please configure only those that 124 | // are actually useful in your network. 125 | // 126 | // For a complete list of options currently supported by Kea, see 127 | // Section 7.2.8 "Standard DHCPv4 Options". Kea also supports 128 | // vendor options (see Section 7.2.10) and allows users to define their 129 | // own custom options (see Section 7.2.9). 130 | "option-data": [ 131 | // When specifying options, you typically need to specify 132 | // one of (name or code) and data. The full option specification 133 | // covers name, code, space, csv-format and data. 134 | // space defaults to "dhcp4" which is usually correct, unless you 135 | // use encapsulate options. csv-format defaults to "true", so 136 | // this is also correct, unless you want to specify the whole 137 | // option value as long hex string. For example, to specify 138 | // domain-name-servers you could do this: 139 | // { 140 | // "name": "domain-name-servers", 141 | // "code": 6, 142 | // "csv-format": "true", 143 | // "space": "dhcp4", 144 | // "data": "192.0.2.1, 192.0.2.2" 145 | // } 146 | // but it's a lot of writing, so it's easier to do this instead: 147 | { 148 | "name": "domain-name-servers", 149 | "data": "192.0.2.1, 192.0.2.2" 150 | }, 151 | 152 | // Typically people prefer to refer to options by their names, so they 153 | // don't need to remember the code names. However, some people like 154 | // to use numerical values. For example, option "domain-name" uses 155 | // option code 15, so you can reference to it either by 156 | // "name": "domain-name" or "code": 15. 157 | { 158 | "code": 15, 159 | "data": "example.org" 160 | }, 161 | 162 | // Domain search is also a popular option. It tells the client to 163 | // attempt to resolve names within those specified domains. For 164 | // example, name "foo" would be attempted to be resolved as 165 | // foo.mydomain.example.com and if it fails, then as foo.example.com 166 | { 167 | "name": "domain-search", 168 | "data": "mydomain.example.com, example.com" 169 | }, 170 | 171 | // String options that have a comma in their values need to have 172 | // it escaped (i.e. each comma is preceded by two backslashes). 173 | // That's because commas are reserved for separating fields in 174 | // compound options. At the same time, we need to be conformant 175 | // with JSON spec, that does not allow "\,". Therefore the 176 | // slightly uncommon double backslashes notation is needed. 177 | 178 | // Legal JSON escapes are \ followed by "\/bfnrt character 179 | // or \u followed by 4 hexadecimal numbers (currently Kea 180 | // supports only \u0000 to \u00ff code points). 181 | // CSV processing translates '\\' into '\' and '\,' into ',' 182 | // only so for instance '\x' is translated into '\x'. But 183 | // as it works on a JSON string value each of these '\' 184 | // characters must be doubled on JSON input. 185 | { 186 | "name": "boot-file-name", 187 | "data": "EST5EDT4\\,M3.2.0/02:00\\,M11.1.0/02:00" 188 | }, 189 | 190 | // Options that take integer values can either be specified in 191 | // dec or hex format. Hex format could be either plain (e.g. abcd) 192 | // or prefixed with 0x (e.g. 0xabcd). 193 | { 194 | "name": "default-ip-ttl", 195 | "data": "0xf0" 196 | } 197 | 198 | // Note that Kea provides some of the options on its own. In particular, 199 | // it sends IP Address lease type (code 51, based on valid-lifetime 200 | // parameter, Subnet mask (code 1, based on subnet definition), Renewal 201 | // time (code 58, based on renew-timer parameter), Rebind time (code 59, 202 | // based on rebind-timer parameter). 203 | ], 204 | 205 | // Other global parameters that can be defined here are option definitions 206 | // (this is useful if you want to use vendor options, your own custom 207 | // options or perhaps handle options that Kea does not handle out of the box 208 | // yet). 209 | 210 | // You can also define classes. If classes are defined, incoming packets 211 | // may be assigned to specific classes. A client class can represent any 212 | // group of devices that share some common characteristic, e.g. Windows 213 | // devices, iphones, broken printers that require special options, etc. 214 | // Based on the class information, you can then allow or reject clients 215 | // to use certain subnets, add special options for them or change values 216 | // of some fixed fields. 217 | "client-classes": [ 218 | { 219 | // This specifies a name of this class. It's useful if you need to 220 | // reference this class. 221 | "name": "voip", 222 | 223 | // This is a test. It is an expression that is being evaluated on 224 | // each incoming packet. It is supposed to evaluate to either 225 | // true or false. If it's true, the packet is added to specified 226 | // class. See Section 12 for a list of available expressions. There 227 | // are several dozens. Section 8.2.14 for more details for DHCPv4 228 | // classification and Section 9.2.19 for DHCPv6. 229 | "test": "substring(option[60].hex,0,6) == 'Aastra'", 230 | 231 | // If a client belongs to this class, you can define extra behavior. 232 | // For example, certain fields in DHCPv4 packet will be set to 233 | // certain values. 234 | "next-server": "192.0.2.254", 235 | "server-hostname": "hal9000", 236 | "boot-file-name": "/dev/null" 237 | 238 | // You can also define option values here if you want devices from 239 | // this class to receive special options. 240 | } 241 | ], 242 | 243 | // Another thing possible here are hooks. Kea supports a powerful mechanism 244 | // that allows loading external libraries that can extract information and 245 | // even influence how the server processes packets. Those libraries include 246 | // additional forensic logging capabilities, ability to reserve hosts in 247 | // more flexible ways, and even add extra commands. For a list of available 248 | // hook libraries, see https://gitlab.isc.org/isc-projects/kea/wikis/Hooks-available. 249 | "hooks-libraries": [ 250 | {"library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"}, 251 | {"library": "/usr/lib/kea/hooks/libdhcp_stat_cmds.so"} 252 | ], 253 | // { 254 | // // Forensic Logging library generates forensic type of audit trail 255 | // // of all devices serviced by Kea, including their identifiers 256 | // // (like MAC address), their location in the network, times 257 | // // when they were active etc. 258 | // "library": "/usr/lib/kea/hooks/libdhcp_legal_log.so", 259 | // "parameters": { 260 | // "path": "/var/lib/kea", 261 | // "base-name": "kea-forensic4" 262 | // } 263 | // }, 264 | // { 265 | // // Flexible identifier (flex-id). Kea software provides a way to 266 | // // handle host reservations that include addresses, prefixes, 267 | // // options, client classes and other features. The reservation can 268 | // // be based on hardware address, DUID, circuit-id or client-id in 269 | // // DHCPv4 and using hardware address or DUID in DHCPv6. However, 270 | // // there are sometimes scenario where the reservation is more 271 | // // complex, e.g. uses other options that mentioned above, uses part 272 | // // of specific options or perhaps even a combination of several 273 | // // options and fields to uniquely identify a client. Those scenarios 274 | // // are addressed by the Flexible Identifiers hook application. 275 | // "library": "/usr/lib/kea/hooks/libdhcp_flex_id.so", 276 | // "parameters": { 277 | // "identifier-expression": "relay4[2].hex" 278 | // } 279 | // } 280 | // ], 281 | 282 | // Below an example of a simple IPv4 subnet declaration. Uncomment to enable 283 | // it. This is a list, denoted with [ ], of structures, each denoted with 284 | // { }. Each structure describes a single subnet and may have several 285 | // parameters. One of those parameters is "pools" that is also a list of 286 | // structures. 287 | "subnet4": [ 288 | { 289 | // This defines the whole subnet. Kea will use this information to 290 | // determine where the clients are connected. This is the whole 291 | // subnet in your network. This is mandatory parameter for each 292 | // subnet. 293 | "subnet": "192.0.2.0/24", 294 | "id": 1, 295 | 296 | // Pools define the actual part of your subnet that is governed 297 | // by Kea. Technically this is optional parameter, but it's 298 | // almost always needed for DHCP to do its job. If you omit it, 299 | // clients won't be able to get addresses, unless there are 300 | // host reservations defined for them. 301 | "pools": [ { "pool": "192.0.2.1 - 192.0.2.200" } ], 302 | 303 | // These are options that are subnet specific. In most cases, 304 | // you need to define at least routers option, as without this 305 | // option your clients will not be able to reach their default 306 | // gateway and will not have Internet connectivity. 307 | "option-data": [ 308 | { 309 | // For each IPv4 subnet you most likely need to specify at 310 | // least one router. 311 | "name": "routers", 312 | "data": "192.0.2.1" 313 | } 314 | ], 315 | 316 | // Kea offers host reservations mechanism. Kea supports reservations 317 | // by several different types of identifiers: hw-address 318 | // (hardware/MAC address of the client), duid (DUID inserted by the 319 | // client), client-id (client identifier inserted by the client) and 320 | // circuit-id (circuit identifier inserted by the relay agent). 321 | // 322 | // Kea also support flexible identifier (flex-id), which lets you 323 | // specify an expression that is evaluated for each incoming packet. 324 | // Resulting value is then used for as an identifier. 325 | // 326 | // Note that reservations are subnet-specific in Kea. This is 327 | // different than ISC DHCP. Keep that in mind when migrating 328 | // your configurations. 329 | "reservations": [ 330 | 331 | // This is a reservation for a specific hardware/MAC address. 332 | // It's a rather simple reservation: just an address and nothing 333 | // else. 334 | { 335 | "hw-address": "1a:1b:1c:1d:1e:1f", 336 | "ip-address": "192.0.2.201" 337 | }, 338 | 339 | // This is a reservation for a specific client-id. It also shows 340 | // the this client will get a reserved hostname. A hostname can 341 | // be defined for any identifier type, not just client-id. 342 | { 343 | "client-id": "01:11:22:33:44:55:66", 344 | "ip-address": "192.0.2.202", 345 | "hostname": "special-snowflake" 346 | }, 347 | 348 | // The third reservation is based on DUID. This reservation defines 349 | // a special option values for this particular client. If the 350 | // domain-name-servers option would have been defined on a global, 351 | // subnet or class level, the host specific values take preference. 352 | { 353 | "duid": "01:02:03:04:05", 354 | "ip-address": "192.0.2.203", 355 | "option-data": [ { 356 | "name": "domain-name-servers", 357 | "data": "10.1.1.202, 10.1.1.203" 358 | } ] 359 | }, 360 | 361 | // The fourth reservation is based on circuit-id. This is an option 362 | // inserted by the relay agent that forwards the packet from client 363 | // to the server. In this example the host is also assigned vendor 364 | // specific options. 365 | // 366 | // When using reservations, it is useful to configure 367 | // reservations-global, reservations-in-subnet, 368 | // reservations-out-of-pool (subnet specific parameters) 369 | // and host-reservation-identifiers (global parameter). 370 | { 371 | "client-id": "01:12:23:34:45:56:67", 372 | "ip-address": "192.0.2.204", 373 | "option-data": [ 374 | { 375 | "name": "vivso-suboptions", 376 | "data": "4491" 377 | }, 378 | { 379 | "name": "tftp-servers", 380 | "space": "vendor-4491", 381 | "data": "10.1.1.202, 10.1.1.203" 382 | } 383 | ] 384 | }, 385 | // This reservation is for a client that needs specific DHCPv4 386 | // fields to be set. Three supported fields are next-server, 387 | // server-hostname and boot-file-name 388 | { 389 | "client-id": "01:0a:0b:0c:0d:0e:0f", 390 | "ip-address": "192.0.2.205", 391 | "next-server": "192.0.2.1", 392 | "server-hostname": "hal9000", 393 | "boot-file-name": "/dev/null" 394 | }, 395 | // This reservation is using flexible identifier. Instead of 396 | // relying on specific field, sysadmin can define an expression 397 | // similar to what is used for client classification, 398 | // e.g. substring(relay[0].option[17],0,6). Then, based on the 399 | // value of that expression for incoming packet, the reservation 400 | // is matched. Expression can be specified either as hex or 401 | // plain text using single quotes. 402 | // 403 | // Note: flexible identifier requires flex_id hook library to be 404 | // loaded to work. 405 | { 406 | "flex-id": "'s0mEVaLue'", 407 | "ip-address": "192.0.2.206" 408 | } 409 | // You can add more reservations here. 410 | ] 411 | // You can add more subnets there. 412 | } 413 | ], 414 | 415 | "shared-networks": [ 416 | { 417 | "name": "test-shared-network-4", 418 | "subnet4": [ 419 | { 420 | "id": 2, 421 | "subnet": "198.51.100.0/24", 422 | "pools": [ { "pool": "198.51.100.1 - 198.51.100.200" } ] 423 | } 424 | ] 425 | } 426 | ], 427 | 428 | // There are many, many more parameters that DHCPv4 server is able to use. 429 | // They were not added here to not overwhelm people with too much 430 | // information at once. 431 | 432 | // Logging configuration starts here. Kea uses different loggers to log various 433 | // activities. For details (e.g. names of loggers), see Chapter 18. 434 | "loggers": [ 435 | { 436 | // This section affects kea-dhcp4, which is the base logger for DHCPv4 437 | // component. It tells DHCPv4 server to write all log messages (on 438 | // severity INFO or more) to a file. 439 | "name": "kea-dhcp4", 440 | "output_options": [ 441 | { 442 | // Specifies the output file. There are several special values 443 | // supported: 444 | // - stdout (prints on standard output) 445 | // - stderr (prints on standard error) 446 | // - syslog (logs to syslog) 447 | // - syslog:name (logs to syslog using specified name) 448 | // Any other value is considered a name of the file 449 | "output": "stdout", 450 | 451 | // Shorter log pattern suitable for use with systemd, 452 | // avoids redundant information 453 | // "pattern": "%-5p %m\n", 454 | 455 | // This governs whether the log output is flushed to disk after 456 | // every write. 457 | // "flush": false, 458 | 459 | // This specifies the maximum size of the file before it is 460 | // rotated. 461 | // "maxsize": 1048576, 462 | 463 | // This specifies the maximum number of rotated files to keep. 464 | // "maxver": 8 465 | } 466 | ], 467 | // This specifies the severity of log messages to keep. Supported values 468 | // are: FATAL, ERROR, WARN, INFO, DEBUG 469 | "severity": "INFO", 470 | 471 | // If DEBUG level is specified, this value is used. 0 is least verbose, 472 | // 99 is most verbose. Be cautious, Kea can generate lots and lots 473 | // of logs if told to do so. 474 | "debuglevel": 0 475 | } 476 | ] 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /tests/test_ui.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import re 3 | from collections.abc import Sequence 4 | from datetime import datetime, timezone 5 | from typing import Any, Literal 6 | 7 | import pynetbox 8 | import pytest 9 | import requests 10 | from netaddr import EUI, IPAddress, IPNetwork, mac_unix_expanded 11 | from playwright.sync_api import Page, expect 12 | 13 | from . import constants 14 | 15 | # This is linked from netbox_kea to avoid import errors 16 | from .kea import KeaClient 17 | 18 | 19 | @pytest.fixture 20 | def requests_session(nb_api: pynetbox.api) -> requests.Session: 21 | s = requests.Session() 22 | s.headers.update( 23 | { 24 | "Authorization": f"Token {nb_api.token}", 25 | "Content-Type": "application/json", 26 | "Accept": "application/json", 27 | } 28 | ) 29 | return s 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def clear_leases(kea_client: KeaClient) -> None: 34 | kea_client.command("lease4-wipe", service=["dhcp4"], check=(0, 3)) 35 | kea_client.command("lease6-wipe", service=["dhcp6"], check=(0, 3)) 36 | 37 | 38 | @pytest.fixture(autouse=True) 39 | def reset_user_preferences( 40 | requests_session: requests.Session, nb_api: pynetbox.api 41 | ) -> None: 42 | r = requests_session.get(url=f"{nb_api.base_url}/users/config/") 43 | r.raise_for_status() 44 | tables_config = r.json().get("tables", {}) 45 | 46 | # pynetbox doesn't support this endpoint 47 | requests_session.patch( 48 | url=f"{nb_api.base_url}/users/config/", 49 | json={"tables": {k: {} for k in tables_config}}, 50 | ).raise_for_status() 51 | 52 | # restore pagination 53 | requests_session.patch( 54 | url=f"{nb_api.base_url}/users/config/", 55 | json={"pagination": {"placement": "bottom"}}, 56 | ).raise_for_status() 57 | 58 | 59 | @pytest.fixture 60 | def with_test_server( 61 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str 62 | ): 63 | server = nb_api.plugins.kea.servers.create(name="test", server_url=kea_url) 64 | page.goto(f"{plugin_base}/servers/{server.id}/") 65 | yield 66 | server.delete() 67 | 68 | 69 | @pytest.fixture 70 | def with_test_server_only6( 71 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str 72 | ): 73 | server = nb_api.plugins.kea.servers.create( 74 | name="only6", server_url=kea_url, dhcp4=False, dhcp6=True 75 | ) 76 | page.goto(f"{plugin_base}/servers/{server.id}/") 77 | yield 78 | server.delete() 79 | 80 | 81 | @pytest.fixture 82 | def with_test_server_only4( 83 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str 84 | ): 85 | server = nb_api.plugins.kea.servers.create( 86 | name="only4", server_url=kea_url, dhcp4=True, dhcp6=False 87 | ) 88 | page.goto(f"{plugin_base}/servers/{server.id}/") 89 | yield 90 | server.delete() 91 | 92 | 93 | @pytest.fixture 94 | def kea_client() -> KeaClient: 95 | return KeaClient("http://localhost:8001") 96 | 97 | 98 | @pytest.fixture 99 | def kea(with_test_server: None, kea_client: KeaClient) -> KeaClient: 100 | return kea_client 101 | 102 | 103 | @pytest.fixture 104 | def plugin_base(netbox_url: str) -> str: 105 | return f"{netbox_url}/plugins/kea" 106 | 107 | 108 | @pytest.fixture 109 | def lease6(kea: KeaClient) -> dict[str, Any]: 110 | lease_ip = "2001:db8:1::1" 111 | kea.command( 112 | "lease6-add", 113 | service=["dhcp6"], 114 | arguments={ 115 | "ip-address": lease_ip, 116 | "duid": "01:02:03:04:05:06:07:08", 117 | "hw-address": "08:08:08:08:08:08", 118 | "iaid": 1, 119 | "valid-lft": 3600, 120 | "hostname": "test-lease6", 121 | "preferred-lft": 7200, 122 | }, 123 | ) 124 | lease = kea.command( 125 | "lease6-get", arguments={"ip-address": lease_ip}, service=["dhcp6"] 126 | )[0]["arguments"] 127 | assert lease is not None 128 | return lease 129 | 130 | 131 | @pytest.fixture 132 | def lease6_netbox_device( 133 | nb_api: pynetbox.api, 134 | test_device_type: int, 135 | test_device_role: int, 136 | test_site: int, 137 | lease6: dict[str, Any], 138 | ): 139 | version = nb_api.version 140 | 141 | lease_ip = lease6["ip-address"] 142 | 143 | device = nb_api.dcim.devices.create( 144 | name=lease6["hostname"], 145 | device_type=test_device_type, 146 | site=test_site, 147 | role=test_device_role, 148 | ) 149 | 150 | interface = nb_api.dcim.interfaces.create( 151 | name="eth0", 152 | type="1000base-t", 153 | device=device.id, 154 | mac_address=lease6["hw-address"], 155 | ) 156 | 157 | if version not in ("4.0", "4.1"): 158 | intf_mac = nb_api.dcim.mac_addresses.create( 159 | mac_address=lease6["hw-address"], 160 | assigned_object_type="dcim.interface", 161 | assigned_object_id=interface.id, 162 | ) 163 | assert interface.update({"primary_mac_address": intf_mac.id}) 164 | 165 | ip = nb_api.ipam.ip_addresses.create( 166 | address=f"{lease_ip}/64", 167 | assigned_object_type="dcim.interface", 168 | assigned_object_id=interface.id, 169 | ) 170 | 171 | yield lease_ip 172 | ip.delete() 173 | interface.delete() 174 | device.delete() 175 | 176 | 177 | @pytest.fixture 178 | def lease6_netbox_vm( 179 | nb_api: pynetbox.api, 180 | test_cluster: int, 181 | test_device_role: int, 182 | lease6: dict[str, Any], 183 | ): 184 | version = nb_api.version 185 | 186 | lease_ip = lease6["ip-address"] 187 | 188 | vm = nb_api.virtualization.virtual_machines.create( 189 | name=lease6["hostname"], 190 | cluster=test_cluster, 191 | role=test_device_role, 192 | ) 193 | interface = nb_api.virtualization.interfaces.create( 194 | name="eth0", virtual_machine=vm.id, mac_address=lease6["hw-address"] 195 | ) 196 | if version not in ("4.0", "4.1"): 197 | intf_mac = nb_api.dcim.mac_addresses.create( 198 | mac_address=lease6["hw-address"], 199 | assigned_object_type="virtualization.vminterface", 200 | assigned_object_id=interface.id, 201 | ) 202 | assert interface.update({"primary_mac_address": intf_mac.id}) 203 | 204 | ip = nb_api.ipam.ip_addresses.create( 205 | address=f"{lease_ip}/64", 206 | assigned_object_type="virtualization.vminterface", 207 | assigned_object_id=interface.id, 208 | ) 209 | 210 | yield lease_ip 211 | 212 | ip.delete() 213 | interface.delete() 214 | vm.delete() 215 | 216 | 217 | @pytest.fixture 218 | def lease6_netbox_ip(nb_api: pynetbox.api, lease6: dict[str, Any]): 219 | lease_ip = lease6["ip-address"] 220 | ip = nb_api.ipam.ip_addresses.create(address=f"{lease_ip}/64") 221 | yield lease_ip 222 | ip.delete() 223 | 224 | 225 | @pytest.fixture 226 | def lease4(kea: KeaClient) -> dict[str, Any]: 227 | lease_ip = "192.0.2.1" 228 | kea.command( 229 | "lease4-add", 230 | service=["dhcp4"], 231 | arguments={ 232 | "ip-address": lease_ip, 233 | "hw-address": "08:08:08:08:08:08", 234 | "client-id": "18:08:08:08:08:08", 235 | "hostname": "test-lease4", 236 | }, 237 | ) 238 | lease = kea.command( 239 | "lease4-get", arguments={"ip-address": lease_ip}, service=["dhcp4"] 240 | )[0]["arguments"] 241 | assert lease is not None 242 | return lease 243 | 244 | 245 | @pytest.fixture 246 | def lease4_netbox_device( 247 | nb_api: pynetbox.api, 248 | test_device_type: int, 249 | test_device_role: int, 250 | test_site: int, 251 | lease4: dict[str, Any], 252 | ): 253 | version = nb_api.version 254 | device_role_key = "role" 255 | 256 | lease_ip = lease4["ip-address"] 257 | 258 | device = nb_api.dcim.devices.create( 259 | name=lease4["hostname"], 260 | device_type=test_device_type, 261 | site=test_site, 262 | **{device_role_key: test_device_role}, 263 | ) 264 | 265 | interface = nb_api.dcim.interfaces.create( 266 | name="eth0", 267 | type="1000base-t", 268 | device=device.id, 269 | mac_address=lease4["hw-address"], 270 | ) 271 | 272 | if version not in ("4.0", "4.1"): 273 | intf_mac = nb_api.dcim.mac_addresses.create( 274 | mac_address=lease4["hw-address"], 275 | assigned_object_type="dcim.interface", 276 | assigned_object_id=interface.id, 277 | ) 278 | assert interface.update({"primary_mac_address": intf_mac.id}) 279 | 280 | ip = nb_api.ipam.ip_addresses.create( 281 | address=f"{lease_ip}/24", 282 | assigned_object_type="dcim.interface", 283 | assigned_object_id=interface.id, 284 | ) 285 | 286 | yield lease_ip 287 | ip.delete() 288 | interface.delete() 289 | device.delete() 290 | 291 | 292 | @pytest.fixture 293 | def lease4_netbox_vm( 294 | nb_api: pynetbox.api, 295 | test_cluster: int, 296 | test_device_role: int, 297 | lease4: dict[str, Any], 298 | ): 299 | version = nb_api.version 300 | 301 | lease_ip = lease4["ip-address"] 302 | 303 | vm = nb_api.virtualization.virtual_machines.create( 304 | name=lease4["hostname"], 305 | cluster=test_cluster, 306 | role=test_device_role, 307 | ) 308 | 309 | interface = nb_api.virtualization.interfaces.create( 310 | name="eth0", virtual_machine=vm.id, mac_address=lease4["hw-address"] 311 | ) 312 | 313 | if version not in ("4.0", "4.1"): 314 | intf_mac = nb_api.dcim.mac_addresses.create( 315 | mac_address=lease4["hw-address"], 316 | assigned_object_type="virtualization.vminterface", 317 | assigned_object_id=interface.id, 318 | ) 319 | assert interface.update({"primary_mac_address": intf_mac.id}) 320 | 321 | ip = nb_api.ipam.ip_addresses.create( 322 | address=f"{lease_ip}/24", 323 | assigned_object_type="virtualization.vminterface", 324 | assigned_object_id=interface.id, 325 | ) 326 | 327 | yield lease_ip 328 | 329 | ip.delete() 330 | interface.delete() 331 | vm.delete() 332 | 333 | 334 | @pytest.fixture 335 | def lease4_netbox_ip(nb_api: pynetbox.api, lease4: dict[str, Any]): 336 | lease_ip = lease4["ip-address"] 337 | ip = nb_api.ipam.ip_addresses.create(address=f"{lease_ip}/24") 338 | yield lease_ip 339 | ip.delete() 340 | 341 | 342 | @pytest.fixture 343 | def leases6_250(kea: KeaClient) -> None: 344 | for i in range(1, 251): 345 | kea.command( 346 | "lease6-add", 347 | service=["dhcp6"], 348 | arguments={ 349 | "ip-address": f"2001:db8:1::{i:x}", 350 | "duid": str(EUI(i * 10, dialect=mac_unix_expanded)), 351 | "hw-address": str(EUI(i, dialect=mac_unix_expanded)), 352 | "iaid": i, 353 | "valid-lft": 3600, 354 | "hostname": f"test-lease6-{i}", 355 | "preferred-lft": 7200, 356 | }, 357 | ) 358 | 359 | 360 | @pytest.fixture 361 | def leases4_250(kea: KeaClient) -> None: 362 | for i in range(1, 251): 363 | kea.command( 364 | "lease4-add", 365 | service=["dhcp4"], 366 | arguments={ 367 | "ip-address": f"192.0.2.{i}", 368 | "client-id": str(EUI(i * 10, dialect=mac_unix_expanded)), 369 | "hw-address": str(EUI(i, dialect=mac_unix_expanded)), 370 | "hostname": f"test-lease4-{i}", 371 | }, 372 | ) 373 | 374 | 375 | @pytest.fixture(scope="function") 376 | def netbox_user_permissions() -> list[dict[str, list[Any]]]: 377 | return [{"actions": [], "object_types": []}] 378 | 379 | 380 | @pytest.fixture(scope="function", autouse=True) 381 | def netbox_login( 382 | page: Page, 383 | netbox_url: str, 384 | netbox_username: str, 385 | netbox_password: str, 386 | netbox_user_permissions: list[dict[str, list[Any]]], 387 | nb_api: pynetbox.api, 388 | ): 389 | to_delete = [] 390 | if netbox_username != "admin": 391 | nb_api.users.users.filter(username=netbox_username).delete() 392 | nb_api.users.permissions.all(0).delete() 393 | user = nb_api.users.users.create( 394 | username=netbox_username, password=netbox_password 395 | ) 396 | to_delete.append(user) 397 | for permission in netbox_user_permissions: 398 | p = nb_api.users.permissions.create( 399 | name=netbox_username, 400 | actions=permission["actions"], 401 | object_types=permission["object_types"], 402 | users=[user.id], 403 | ) 404 | to_delete.append(p) 405 | 406 | page.goto(f"{netbox_url}/login/") 407 | page.get_by_label("Username").fill(netbox_username) 408 | page.get_by_label("Password").fill(netbox_password) 409 | page.get_by_role("button", name="Sign In").click() 410 | 411 | yield 412 | 413 | for obj in to_delete: 414 | assert obj.delete() 415 | 416 | 417 | @pytest.fixture(scope="session") 418 | def test_tag(nb_api: pynetbox.api): 419 | tag = nb_api.extras.tags.create(name="kea-test", slug="kea-test") 420 | assert tag is not None 421 | yield tag.name 422 | tag.delete() 423 | 424 | 425 | @pytest.fixture(scope="session") 426 | def test_site(nb_api: pynetbox.api): 427 | site = nb_api.dcim.sites.create(name="Test Site", slug="test-site") 428 | yield site.id 429 | site.delete() 430 | 431 | 432 | @pytest.fixture(scope="session") 433 | def test_device_type(nb_api: pynetbox.api): 434 | manufacturer = nb_api.dcim.manufacturers.create( 435 | name="Test Manufacturer", slug="test-manufacturer" 436 | ) 437 | device_type = nb_api.dcim.device_types.create( 438 | manufacturer=manufacturer.id, 439 | model="test model", 440 | slug="test-model", 441 | ) 442 | yield device_type.id 443 | device_type.delete() 444 | manufacturer.delete() 445 | 446 | 447 | @pytest.fixture(scope="session") 448 | def test_device_role(nb_api: pynetbox.api): 449 | role = nb_api.dcim.device_roles.create(name="Test Role", slug="test-role") 450 | yield role.id 451 | role.delete() 452 | 453 | 454 | @pytest.fixture(scope="session") 455 | def test_cluster(nb_api: pynetbox.api): 456 | cluster_type = nb_api.virtualization.cluster_types.create( 457 | name="test cluster type", 458 | slug="test-cluster-type", 459 | ) 460 | cluster = nb_api.virtualization.clusters.create( 461 | name="Test Cluster", type=cluster_type.id 462 | ) 463 | yield cluster.id 464 | cluster.delete() 465 | cluster_type.delete() 466 | 467 | 468 | def search_lease(page: Page, version: Literal[4, 6], by: str, q: str) -> None: 469 | page.get_by_role("link", name=f"DHCPv{version} Leases").click() 470 | page.locator("#id_q").fill(q) 471 | page.locator("#id_by + div.form-select").click() 472 | page.locator("#id_by-ts-dropdown").get_by_role( 473 | "option", name=by, exact=True 474 | ).click() 475 | with page.expect_response(re.compile(f"/leases{version}/")) as r: 476 | page.get_by_role("button", name="Search").click() 477 | assert r.value.ok 478 | 479 | 480 | def search_lease_related(page: Page, model: str) -> None: 481 | page.locator("span.dropdown > a.btn-secondary").click() 482 | page.get_by_role("link", name=f"Search {model}").click() 483 | expect(page.get_by_text("Showing 1-1 of 1")).to_have_count(1) 484 | 485 | 486 | def expect_form_error_search(page: Page, b: bool) -> None: 487 | expect(page.locator("#id_q + div.form-text.text-danger")).to_have_count(int(b)) 488 | 489 | 490 | def _version_ge_43(page: Page) -> bool: 491 | """ 492 | Return True if the version is >= 4.3. 493 | """ 494 | 495 | old_version_strings = ( 496 | "(v4.0.11)", 497 | '
  • NetBox Community v4.1.11
  • ', 498 | "NetBox Community v4.2", 499 | ) 500 | 501 | content = page.content() 502 | 503 | return not any(s in content for s in old_version_strings) 504 | 505 | 506 | def configure_table(page: Page, *selected_coumns: str) -> None: 507 | page.get_by_role("button", name=re.compile("Configure Table")).click() 508 | 509 | # Clear all selected columns 510 | remove_button = page.get_by_text("Remove") 511 | selected_count = page.locator("#id_columns > option").count() 512 | for i in range(selected_count): 513 | page.locator("#id_columns > option").first.click() 514 | remove_button.click() 515 | 516 | for sc in selected_coumns: 517 | page.locator(f'#id_available_columns > option[value="{sc}"]').click() 518 | page.get_by_text("Add", exact=True).click() 519 | 520 | with page.expect_navigation(): 521 | page.get_by_role("button", name="Save").click() 522 | 523 | 524 | @pytest.mark.parametrize( 525 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 526 | [ 527 | ("admin", "admin", None), 528 | ( 529 | "user", 530 | "user12Characters", 531 | [{"actions": ["view"], "object_types": ["netbox_kea.server"]}], 532 | ), 533 | ], 534 | ) 535 | def test_navigation_view(page: Page) -> None: 536 | page.get_by_role("button", name="󰐱 Plugins").click() 537 | page.get_by_role("link", name="Servers").click() 538 | 539 | expect(page).to_have_title(re.compile("^Servers.*")) 540 | 541 | 542 | @pytest.mark.parametrize( 543 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 544 | [ 545 | ("admin", "admin", None), 546 | ( 547 | "user", 548 | "user12Characters", 549 | [{"actions": ["view", "add"], "object_types": ["netbox_kea.server"]}], 550 | ), 551 | ], 552 | ) 553 | def test_navigation_add(page: Page) -> None: 554 | page.get_by_role("button", name="󰐱 Plugins").click() 555 | page.get_by_role("link", name="Servers").hover() 556 | page.get_by_role("link", name="󱇬", exact=True).click() 557 | 558 | expect(page).to_have_title(re.compile("^Add a new server.*")) 559 | 560 | 561 | @pytest.mark.parametrize( 562 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 563 | [ 564 | ( 565 | "user", 566 | "user12Characters", 567 | [], 568 | ), 569 | ], 570 | ) 571 | def test_navigation_view_no_access(page: Page) -> None: 572 | expect(page.get_by_role("button", name="󰐱 Plugins")).to_have_count(0) 573 | 574 | 575 | @pytest.mark.parametrize( 576 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 577 | [ 578 | ( 579 | "user", 580 | "user12Characters", 581 | [{"actions": ["view"], "object_types": ["netbox_kea.server"]}], 582 | ), 583 | ], 584 | ) 585 | def test_navigation_add_no_access(page: Page) -> None: 586 | page.get_by_role("button", name="󰐱 Plugins").click() 587 | page.get_by_role("link", name="Servers").hover() 588 | expect(page.get_by_role("link", name="󱇬", exact=True)).to_have_count(0) 589 | 590 | 591 | def test_server_add_delete( 592 | page: Page, plugin_base: str, kea_url: str, nb_api: pynetbox.api 593 | ) -> None: 594 | server_name = "test_server" 595 | page.goto(f"{plugin_base}/servers/add/") 596 | expect(page).to_have_title(re.compile("^Add a new server.*")) 597 | 598 | page.get_by_label("Name", exact=True).fill(server_name) 599 | page.get_by_label("Server URL", exact=True).fill(kea_url) 600 | page.get_by_role("button", name="Create", exact=True).click() 601 | 602 | expect(page).to_have_title(re.compile(f"^{server_name}")) 603 | server = nb_api.plugins.kea.servers.get(name=server_name) 604 | assert server is not None 605 | 606 | page.locator(".btn-list > a.btn-red").click() 607 | page.locator(".modal-footer > button.btn-danger").click() # Confirm dialog 608 | 609 | server = nb_api.plugins.kea.servers.get(name=server_name) 610 | assert server is None 611 | 612 | 613 | def test_server_bulk_delete( 614 | page: Page, plugin_base: str, nb_api: pynetbox.api, kea_url: str 615 | ): 616 | nb_api.plugins.kea.servers.create( 617 | [ 618 | {"name": "server1", "server_url": kea_url}, 619 | {"name": "server2", "server_url": kea_url}, 620 | ] 621 | ) 622 | 623 | page.goto(f"{plugin_base}/servers/") 624 | page.get_by_role("checkbox", name="Toggle All").click() 625 | page.get_by_role("button", name="Delete Selected").click() 626 | page.locator('button.btn-danger[type="submit"]').click() 627 | 628 | assert nb_api.plugins.kea.servers.count() == 0 629 | 630 | 631 | def test_server_edit(page: Page, kea: KeaClient) -> None: 632 | new_name = "a_new_name" 633 | page.get_by_role("button", name="Edit").click() 634 | page.get_by_label("Name", exact=True).fill(new_name) 635 | page.get_by_role("button", name="Save").click() 636 | expect(page).to_have_title(re.compile(f"^{new_name}")) 637 | 638 | 639 | def test_server_status(page: Page, kea: KeaClient) -> None: 640 | page.get_by_role("link", name="Status").click() 641 | 642 | ctrl_version = kea.command("version-get")[0]["arguments"]["extended"] 643 | dhcp4_version = kea.command("version-get", service=["dhcp4"])[0]["arguments"][ 644 | "extended" 645 | ] 646 | dhcp6_version = kea.command("version-get", service=["dhcp6"])[0]["arguments"][ 647 | "extended" 648 | ] 649 | 650 | locator = page.locator(".tab-content") 651 | expect(locator).to_contain_text(ctrl_version) 652 | expect(locator).to_contain_text(dhcp4_version) 653 | expect(locator).to_contain_text(dhcp6_version) 654 | 655 | 656 | @pytest.mark.parametrize( 657 | ("family", "subnets"), 658 | ( 659 | ( 660 | 4, 661 | ( 662 | (1, "192.0.2.0/24", None), 663 | (2, "198.51.100.0/24", "test-shared-network-4"), 664 | ), 665 | ), 666 | ( 667 | 6, 668 | ( 669 | (1, "2001:db8:1::/64", None), 670 | (2, "2001:db8:2::/64", "test-shared-network-6"), 671 | ), 672 | ), 673 | ), 674 | ) 675 | def test_dhcp_subnets( 676 | page: Page, 677 | kea: KeaClient, 678 | family: str, 679 | subnets: Sequence[tuple[str, str, str | None]], 680 | ) -> None: 681 | for i, (subnet_id, subnet, shared_network) in enumerate(subnets): 682 | page.get_by_role("link", name=f"DHCPv{family} Subnets").click() 683 | configure_table(page, "id", "subnet", "shared_network") 684 | rows = page.locator("table > tbody > tr") 685 | tds = rows.nth(i).locator("td") 686 | 687 | # Check column data 688 | # 0: ID 689 | # 1: Subnet 690 | # 2: Shared Network 691 | expect(tds.nth(0)).to_contain_text(str(subnet_id)) 692 | expect(tds.nth(1)).to_contain_text(subnet) 693 | expect(tds.nth(2)).to_contain_text(shared_network or "—") 694 | 695 | with page.expect_response(re.compile(f"/leases{family}/")) as r: 696 | page.get_by_role("link", name=subnet).click() 697 | assert r.value.ok 698 | expect(page.locator("#id_q")).to_have_value(subnet) 699 | expect( 700 | page.locator("#id_by + div.form-select > div.ts-control > div.item") 701 | ).to_have_text("Subnet") 702 | 703 | 704 | @pytest.mark.parametrize( 705 | ("family", "all_data", "expected_data"), 706 | ( 707 | ( 708 | 4, 709 | True, 710 | [ 711 | {"ID": str(1), "Subnet": "192.0.2.0/24", "Shared Network": ""}, 712 | { 713 | "ID": str(2), 714 | "Subnet": "198.51.100.0/24", 715 | "Shared Network": "test-shared-network-4", 716 | }, 717 | ], 718 | ), 719 | ( 720 | 4, 721 | False, 722 | [ 723 | {"ID": str(1), "Subnet": "192.0.2.0/24"}, 724 | {"ID": str(2), "Subnet": "198.51.100.0/24"}, 725 | ], 726 | ), 727 | ( 728 | 6, 729 | True, 730 | [ 731 | {"ID": str(1), "Subnet": "2001:db8:1::/64", "Shared Network": ""}, 732 | { 733 | "ID": str(2), 734 | "Subnet": "2001:db8:2::/64", 735 | "Shared Network": "test-shared-network-6", 736 | }, 737 | ], 738 | ), 739 | ( 740 | 6, 741 | False, 742 | [ 743 | {"ID": str(1), "Subnet": "2001:db8:1::/64"}, 744 | {"ID": str(2), "Subnet": "2001:db8:2::/64"}, 745 | ], 746 | ), 747 | ), 748 | ) 749 | def test_dhcp_subnets_export_csv( 750 | page: Page, kea: KeaClient, family: int, all_data: bool, expected_data: bool 751 | ) -> None: 752 | page.get_by_role("link", name=f"DHCPv{family} Subnets").click() 753 | 754 | if all_data is False: 755 | configure_table(page, "id", "subnet") 756 | 757 | page.get_by_role("button", name="Export").click() 758 | with page.expect_download() as dl: 759 | page.get_by_role( 760 | "link", name="All Data (CSV)" if all_data is True else "Current View" 761 | ).click() 762 | dl = dl.value 763 | assert dl.suggested_filename.endswith(".csv") 764 | 765 | with open(dl.path()) as f: 766 | r = csv.DictReader(f) 767 | have_rows = sorted(r, key=lambda x: x["ID"]) 768 | assert have_rows == expected_data 769 | 770 | 771 | @pytest.mark.parametrize("family", (4, 6)) 772 | def test_dhcp_subnets_configure_table(page: Page, kea: KeaClient, family: int) -> None: 773 | page.get_by_role("link", name=f"DHCPv{family} Subnets").click() 774 | 775 | configure_table(page, "subnet") 776 | expect(page.locator(".object-list > thead > tr > th > a")).to_have_text( 777 | ["Subnet", ""] 778 | ) 779 | 780 | configure_table(page, "subnet", "shared_network") 781 | expect(page.locator(".object-list > thead > tr > th > a")).to_have_text( 782 | ["Subnet", "Shared Network", ""] 783 | ) 784 | 785 | 786 | @pytest.mark.parametrize( 787 | ("version", "by", "q"), 788 | ( 789 | (6, "IP Address", "192.0.2.0"), 790 | (6, "IP Address", "192.0.2.0/24"), 791 | (6, "IP Address", "2001:db8::/64"), 792 | (6, "Subnet", "abc"), 793 | (6, "Subnet", "2001:db8::"), 794 | (6, "Subnet", "2001:db8::10/64"), 795 | (6, "Subnet", "192.0.2.0"), 796 | (6, "Subnet", "192.0.2.0/24"), 797 | (6, "Subnet ID", "foo"), 798 | (6, "Subnet ID", "192.0.2.0/24"), 799 | (6, "Subnet ID", "2001:db8::/64"), 800 | (6, "DUID", "foo"), 801 | (6, "DUID", "192.0.2.0"), 802 | (6, "DUID", "2001:db8::"), 803 | (6, "DUID", "0"), # Too short 804 | (6, "DUID", "00" * (constants.DUID_MAX_OCTETS + 1)), 805 | (4, "IP Address", "2001:db8::"), 806 | (4, "IP Address", "2001:db8::/64"), 807 | (4, "IP Address", "192.0.2.0/24"), 808 | (4, "Hardware Address", "foo"), 809 | (4, "Hardware Address", "192.0.2.0"), 810 | (4, "Hardware Address", "2001:db8::"), 811 | (4, "Client ID", "foo"), 812 | (4, "Client ID", "192.0.2.0"), 813 | (4, "Client ID", "2001:db8::"), 814 | (4, "Client ID", "0"), 815 | (4, "Client ID", "00" * (constants.CLIENT_ID_MAX_OCTETS + 1)), 816 | (4, "Subnet", "abc"), 817 | (4, "Subnet", "2001:db8::"), 818 | (4, "Subnet", "192.0.2.0"), 819 | (4, "Subnet", "192.0.2.10/24"), 820 | (4, "Subnet", "2001:db8::/64"), 821 | (4, "Subnet ID", "foo"), 822 | (4, "Subnet ID", "192.0.2.0/24"), 823 | (4, "Subnet ID", "2001:db8::/64"), 824 | ), 825 | ) 826 | def test_dhcp_lease_invalid_search_values( 827 | page: Page, kea: KeaClient, version: int, by: str, q: str 828 | ) -> None: 829 | page.get_by_role("link", name=f"DHCPv{version} Leases").click() 830 | page.locator("#id_q").fill(q) 831 | page.locator("#id_by + div.form-select").click() 832 | page.locator("#id_by-ts-dropdown").get_by_role( 833 | "option", name=by, exact=True 834 | ).click() 835 | page.get_by_role("button", name="Search").click() 836 | expect_form_error_search(page, True) 837 | expect(page.locator("div.table-container")).to_have_count(0) 838 | 839 | 840 | @pytest.mark.parametrize("family", (4, 6)) 841 | def test_dhcp_lease_all_columns( 842 | page: Page, kea: KeaClient, family: Literal[6, 4], request: pytest.FixtureRequest 843 | ) -> None: 844 | lease_args = request.getfixturevalue(f"lease{family}") 845 | lease_ip = lease_args["ip-address"] 846 | 847 | lease = kea.command( 848 | f"lease{family}-get", 849 | service=[f"dhcp{family}"], 850 | arguments={"ip-address": lease_ip}, 851 | )[0]["arguments"] 852 | assert lease is not None 853 | 854 | def search() -> None: 855 | search_lease(page, family, "IP Address", lease_ip) 856 | 857 | search() 858 | 859 | if family == 6: 860 | configure_table( 861 | page, 862 | "ip_address", 863 | "hostname", 864 | "hw_address", 865 | "cltt", 866 | "subnet_id", 867 | "valid_lft", 868 | "duid", 869 | "type", 870 | "preferred_lft", 871 | "expires_at", 872 | "expires_in", 873 | "iaid", 874 | ) 875 | # NetBox v4.3 removes the query when saving the table config. 876 | # See table tableConfig.ts in https://github.com/netbox-community/netbox/pull/19284. 877 | if _version_ge_43(page): 878 | search() 879 | 880 | def check(): 881 | cltt = datetime.fromtimestamp(lease["cltt"], timezone.utc) 882 | expect(page.locator("table.object-list > tbody > tr > td")).to_have_text( 883 | [ 884 | re.compile(".*"), # select 885 | lease["ip-address"], 886 | lease["hostname"], 887 | lease["hw-address"], 888 | f"{cltt.date().isoformat()} {cltt.time().isoformat()}", 889 | str(lease["subnet-id"]), 890 | "01:00:00", 891 | lease["duid"], 892 | lease["type"], 893 | "02:00:00", 894 | re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"), 895 | re.compile(r"\d{2}:\d{2}:\d{2}"), 896 | str(lease["iaid"]), 897 | re.compile(".*"), # actions 898 | ] 899 | ) 900 | 901 | else: 902 | configure_table( 903 | page, 904 | "ip_address", 905 | "hostname", 906 | "hw_address", 907 | "cltt", 908 | "subnet_id", 909 | "valid_lft", 910 | "expires_at", 911 | "expires_in", 912 | "client_id", 913 | ) 914 | if _version_ge_43(page): 915 | search() 916 | 917 | def check(): 918 | cltt = datetime.fromtimestamp(lease["cltt"], timezone.utc) 919 | expect(page.locator("table.object-list > tbody > tr > td")).to_have_text( 920 | [ 921 | "", # select 922 | lease["ip-address"], 923 | lease["hostname"], 924 | lease["hw-address"], 925 | f"{cltt.date().isoformat()} {cltt.time().isoformat()}", 926 | str(lease["subnet-id"]), 927 | "01:00:00", 928 | re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}"), 929 | re.compile(r"\d{2}:\d{2}:\d{2}"), 930 | lease["client-id"], 931 | re.compile(".*"), # actions 932 | ] 933 | ) 934 | 935 | check() 936 | 937 | # Should be the same on reload 938 | page.reload() 939 | check() 940 | 941 | 942 | @pytest.mark.parametrize( 943 | ("family", "all_data", "check_fields"), 944 | ( 945 | ( 946 | 6, 947 | True, 948 | ( 949 | ("IP Address", "ip-address"), 950 | ("Hardware Address", "hw-address"), 951 | ("DUID", "duid"), 952 | ("IAID", "iaid"), 953 | ), 954 | ), 955 | ( 956 | 6, 957 | False, 958 | ( 959 | ("IP Address", "ip-address"), 960 | ("Hostname", "hostname"), 961 | ("Subnet ID", "subnet-id"), 962 | ), 963 | ), 964 | ( 965 | 4, 966 | True, 967 | ( 968 | ("IP Address", "ip-address"), 969 | ("Hardware Address", "hw-address"), 970 | ("Client ID", "client-id"), 971 | ("Hostname", "hostname"), 972 | ), 973 | ), 974 | ( 975 | 4, 976 | False, 977 | ( 978 | ("IP Address", "ip-address"), 979 | ("Hostname", "hostname"), 980 | ("Subnet ID", "subnet-id"), 981 | ), 982 | ), 983 | ), 984 | ) 985 | def test_dhcp_export_csv_all( 986 | page: Page, 987 | kea: KeaClient, 988 | family: Literal[4, 6], 989 | all_data: bool, 990 | check_fields: tuple[tuple[str, str], ...], 991 | request: pytest.FixtureRequest, 992 | ): 993 | request.getfixturevalue(f"leases{family}_250") 994 | 995 | leases = kea.command(f"lease{family}-get-all", service=[f"dhcp{family}"])[0][ 996 | "arguments" 997 | ]["leases"] 998 | 999 | def search() -> None: 1000 | return search_lease( 1001 | page, family, "Subnet", "2001:db8:1::/64" if family == 6 else "192.0.2.0/24" 1002 | ) 1003 | 1004 | search() 1005 | configure_table(page, "ip_address", "hostname", "subnet_id") 1006 | if _version_ge_43(page): 1007 | search() 1008 | 1009 | page.get_by_role("button", name="Export").click() 1010 | with page.expect_download() as dl: 1011 | page.get_by_role( 1012 | "link", name="All Data (CSV)" if all_data is True else "Current View" 1013 | ).click() 1014 | dl = dl.value 1015 | assert dl.suggested_filename.endswith(".csv") 1016 | 1017 | with open(dl.path()) as f: 1018 | r = csv.DictReader(f) 1019 | have_rows = sorted(r, key=lambda x: x["IP Address"]) 1020 | 1021 | want_rows = sorted(leases, key=lambda x: x["ip-address"]) 1022 | 1023 | assert len(have_rows) == len(want_rows) 1024 | for have_dict, want_dict in zip(have_rows, want_rows): 1025 | for have_key, want_key in check_fields: 1026 | assert have_dict[have_key] == str(want_dict[want_key]) 1027 | 1028 | 1029 | @pytest.mark.parametrize("family", (6, 4)) 1030 | def test_lease_delete( 1031 | page: Page, 1032 | kea: KeaClient, 1033 | family: Literal[6, 4], 1034 | request: pytest.FixtureRequest, 1035 | ) -> None: 1036 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1037 | 1038 | search_lease(page, family, "IP Address", ip) 1039 | 1040 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1041 | page.locator('input[name="pk"]').check() 1042 | 1043 | url = page.url 1044 | 1045 | page.get_by_role("button", name="Delete Selected").click() 1046 | page.locator('button[name="_confirm"]').click() 1047 | expect(page.locator(".toast-body")).to_have_text( 1048 | re.compile(f"Deleted 1 DHCPv{family} lease\\(s\\)") 1049 | ) 1050 | 1051 | kea.command( 1052 | f"lease{family}-get", 1053 | service=[f"dhcp{family}"], 1054 | arguments={"ip-address": ip}, 1055 | check=(3,), 1056 | ) 1057 | 1058 | expect(page).to_have_url(url) 1059 | 1060 | 1061 | @pytest.mark.parametrize( 1062 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 1063 | [ 1064 | ( 1065 | "delete-user", 1066 | "delete-user12Characters", 1067 | [ 1068 | { 1069 | "actions": ["view", "bulk_delete_lease_from"], 1070 | "object_types": ["netbox_kea.server"], 1071 | } 1072 | ], 1073 | ), 1074 | ( 1075 | "no-delete-user", 1076 | "no-delete-user12Characters", 1077 | [{"actions": ["view"], "object_types": ["netbox_kea.server"]}], 1078 | ), 1079 | ], 1080 | ) 1081 | @pytest.mark.parametrize("family", (6, 4)) 1082 | def test_lease_delete_no_permission( 1083 | page: Page, 1084 | kea: KeaClient, 1085 | netbox_username: str, 1086 | family: Literal[6, 4], 1087 | request: pytest.FixtureRequest, 1088 | ) -> None: 1089 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1090 | 1091 | search_lease(page, family, "IP Address", ip) 1092 | 1093 | expected_count = int(netbox_username.startswith("delete")) 1094 | 1095 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1096 | 1097 | expect(page.locator('input[name="pk"]')).to_have_count(expected_count) 1098 | expect(page.get_by_role("button", name="Delete Selected")).to_have_count( 1099 | expected_count 1100 | ) 1101 | 1102 | 1103 | @pytest.mark.parametrize( 1104 | ("netbox_username", "netbox_password", "netbox_user_permissions"), 1105 | [ 1106 | ( 1107 | "delete-user", 1108 | "delete-user12Characters", 1109 | [ 1110 | { 1111 | "actions": ["view", "bulk_delete_lease_from"], 1112 | "object_types": ["netbox_kea.server"], 1113 | } 1114 | ], 1115 | ), 1116 | ], 1117 | ) 1118 | @pytest.mark.parametrize("family", (6, 4)) 1119 | def test_lease_delete_no_permission_on_confirm( 1120 | page: Page, 1121 | kea: KeaClient, 1122 | nb_api: pynetbox.api, 1123 | netbox_username: str, 1124 | family: Literal[6, 4], 1125 | request: pytest.FixtureRequest, 1126 | ) -> None: 1127 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1128 | 1129 | search_lease(page, family, "IP Address", ip) 1130 | 1131 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1132 | page.locator('input[name="pk"]').check() 1133 | 1134 | page.get_by_role("button", name="Delete Selected").click() 1135 | 1136 | # Remove bulk_delete_lease_from permission from the user before confirming 1137 | user = nb_api.users.users.get(username=netbox_username) 1138 | assert user is not None 1139 | assert len(user.permissions) == 1 1140 | p = user.permissions[0] 1141 | p.actions = ["view"] 1142 | assert p.save() 1143 | 1144 | page.locator('button[name="_confirm"]').click() 1145 | expect(page.locator("body")).to_have_text( 1146 | "This user does not have permission to delete DHCP leases." 1147 | ) 1148 | 1149 | 1150 | @pytest.mark.parametrize("family", (6, 4)) 1151 | def test_lease_deleted_before_delete( 1152 | page: Page, 1153 | kea: KeaClient, 1154 | family: Literal[6, 4], 1155 | request: pytest.FixtureRequest, 1156 | ) -> None: 1157 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1158 | 1159 | search_lease(page, family, "IP Address", ip) 1160 | 1161 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1162 | page.locator('input[name="pk"]').check() 1163 | page.get_by_role("button", name="Delete Selected").click() 1164 | 1165 | kea.command( 1166 | f"lease{family}-del", service=[f"dhcp{family}"], arguments={"ip-address": ip} 1167 | ) 1168 | 1169 | page.locator('button[name="_confirm"]').click() 1170 | # Kea will return status 3 1171 | expect(page.locator(".toast-body")).to_have_text( 1172 | re.compile(f"Deleted 1 DHCPv{family} lease\\(s\\)") 1173 | ) 1174 | 1175 | 1176 | @pytest.mark.parametrize("family", (6, 4)) 1177 | def test_lease_deleted_invalid_ip( 1178 | page: Page, 1179 | kea: KeaClient, 1180 | family: Literal[6, 4], 1181 | request: pytest.FixtureRequest, 1182 | ) -> None: 1183 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1184 | 1185 | search_lease(page, family, "IP Address", ip) 1186 | 1187 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1188 | pk = page.locator('input[name="pk"]') 1189 | pk.evaluate('node => node.value = "notanip"') 1190 | pk.check() 1191 | page.get_by_role("button", name="Delete Selected").click() 1192 | expect(page.locator(".toast-body")).to_contain_text("Invalid IP") 1193 | 1194 | 1195 | @pytest.mark.parametrize("family", (6, 4)) 1196 | def test_lease_deleted_invalid_ip_confirm( 1197 | page: Page, 1198 | kea: KeaClient, 1199 | family: Literal[6, 4], 1200 | request: pytest.FixtureRequest, 1201 | ) -> None: 1202 | ip = request.getfixturevalue(f"lease{family}")["ip-address"] 1203 | 1204 | search_lease(page, family, "IP Address", ip) 1205 | 1206 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1207 | page.locator('input[name="pk"]').check() 1208 | page.get_by_role("button", name="Delete Selected").click() 1209 | page.locator("#id_pk_0").evaluate('node => node.value = "notanip"') 1210 | page.locator('button[name="_confirm"]').click() 1211 | 1212 | expect(page.locator(".toast-body")).to_contain_text("Invalid IP") 1213 | 1214 | 1215 | @pytest.mark.parametrize( 1216 | ("family", "search_by", "search_value_attr"), 1217 | ( 1218 | (6, "IP Address", "ip-address"), 1219 | (6, "Hostname", "hostname"), 1220 | (6, "DUID", "duid"), 1221 | (6, "Subnet ID", "subnet-id"), 1222 | (4, "IP Address", "ip-address"), 1223 | (4, "Hostname", "hostname"), 1224 | (4, "Hardware Address", "hw-address"), 1225 | (4, "Client ID", "client-id"), 1226 | (4, "Subnet ID", "subnet-id"), 1227 | ), 1228 | ) 1229 | def test_lease_search( 1230 | page: Page, 1231 | family: Literal[6, 4], 1232 | search_by: str, 1233 | search_value_attr: str, 1234 | request: pytest.FixtureRequest, 1235 | ) -> None: 1236 | lease = request.getfixturevalue(f"lease{family}") 1237 | search_lease(page, family, search_by, str(lease[search_value_attr])) 1238 | expect_form_error_search(page, False) 1239 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1240 | expect(page.locator(".object-list > tbody > tr > td").nth(1)).to_have_text( 1241 | lease["ip-address"] 1242 | ) 1243 | 1244 | 1245 | @pytest.mark.parametrize("sep", ("-", "")) 1246 | @pytest.mark.parametrize( 1247 | ("family", "search_by", "search_value_attr"), 1248 | ( 1249 | (6, "DUID", "duid"), 1250 | (4, "Hardware Address", "hw-address"), 1251 | (4, "Client ID", "client-id"), 1252 | ), 1253 | ) 1254 | def test_lease_search_eui_formats( 1255 | page: Page, 1256 | family: Literal[6, 4], 1257 | search_by: str, 1258 | search_value_attr: str, 1259 | sep: str, 1260 | request: pytest.FixtureRequest, 1261 | ) -> None: 1262 | lease = request.getfixturevalue(f"lease{family}") 1263 | search_lease(page, family, search_by, lease[search_value_attr].replace(":", sep)) 1264 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1265 | expect(page.locator(".object-list > tbody > tr > td").nth(1)).to_have_text( 1266 | lease["ip-address"] 1267 | ) 1268 | 1269 | 1270 | def test_lease_search_cisco_style_mac(page: Page, lease4: dict[str, Any]) -> None: 1271 | mac = lease4["hw-address"].replace(":", "") 1272 | cisco_mac = f"{mac[:4]}.{mac[4:8]}.{mac[8:]}" 1273 | search_lease(page, 4, "Hardware Address", cisco_mac) 1274 | expect(page.locator(".object-list > tbody > tr")).to_have_count(1) 1275 | expect(page.locator(".object-list > tbody > tr > td").nth(1)).to_have_text( 1276 | lease4["ip-address"] 1277 | ) 1278 | 1279 | 1280 | @pytest.mark.parametrize( 1281 | "prefix", 1282 | ( 1283 | "2001:db8:1::/124", 1284 | "2001:db8:1::10/124", 1285 | "2001:db8:1::/121", 1286 | "2001:db8:1::/64", 1287 | "::/0", 1288 | "192.0.2.0/29", 1289 | "192.0.2.8/29", 1290 | "192.0.2.0/25", 1291 | "192.0.2.0/24", 1292 | "0.0.0.0/0", 1293 | ), 1294 | ) 1295 | def test_lease_search_by_subnet( 1296 | page: Page, 1297 | prefix: str, 1298 | request: pytest.FixtureRequest, 1299 | ) -> None: 1300 | # per page default is 50 1301 | per_page = 50 1302 | 1303 | net = IPNetwork(prefix) 1304 | family = net.version 1305 | dhcp_scope = ( 1306 | IPNetwork("2001:db8:1::/64") if family == 6 else IPNetwork("192.0.2.0/24") 1307 | ) 1308 | skip_first = dhcp_scope.network in net 1309 | lease_count = min(net.size - int(skip_first), 250) 1310 | request.getfixturevalue(f"leases{family}_250") 1311 | 1312 | search_lease(page, family, "Subnet", str(net)) 1313 | 1314 | def check_count(count: int) -> None: 1315 | expect(page.locator(".object-list > tbody > tr")).to_have_count(count) 1316 | 1317 | first_ip = max(net[int(skip_first)], dhcp_scope[1]) 1318 | 1319 | def click_next() -> None: 1320 | with page.expect_response(re.compile(f"/leases{family}/")) as r: 1321 | page.get_by_role("button", name="Next").click() 1322 | assert r.value.ok 1323 | 1324 | def check_first_row_ip(ip: IPAddress) -> None: 1325 | expect(page.locator(".object-list > tbody > tr > td").nth(1)).to_have_text( 1326 | str(ip) 1327 | ) 1328 | 1329 | check_first_row_ip(first_ip) 1330 | check_count(min(lease_count, per_page)) 1331 | 1332 | for _ in range(int(lease_count / per_page) - 1): 1333 | # Kea doesn't guarantee order... 1334 | first_ip += per_page 1335 | click_next() 1336 | check_first_row_ip(first_ip) 1337 | check_count(per_page) 1338 | 1339 | if net.size > per_page: 1340 | first_ip += per_page 1341 | click_next() 1342 | if first_ip != dhcp_scope.network + 251: 1343 | check_first_row_ip(first_ip) 1344 | check_count(lease_count % per_page) 1345 | else: 1346 | expect(page.locator(".object-list > tbody > tr > td")).to_have_text( 1347 | "— No leases found. —" 1348 | ) 1349 | 1350 | expect(page.get_by_role("button", name="Next")).to_be_disabled() 1351 | 1352 | 1353 | @pytest.mark.parametrize( 1354 | ("family", "subnet_page"), 1355 | ((6, "abc"), (6, "2001:db8:2::"), (4, "abc"), (4, "192.0.3.0")), 1356 | ) 1357 | def test_lease_search_by_subnet_invalid_page( 1358 | page: Page, 1359 | kea: KeaClient, 1360 | plugin_base: str, 1361 | family: Literal[6, 4], 1362 | subnet_page: str, 1363 | ) -> None: 1364 | prefix = "2001:db8:1::/64" if family == 6 else "192.0.2.0/24" 1365 | page.goto(f"{page.url}/leases{family}/?q={prefix}&by=subnet&page={subnet_page}") 1366 | expect(page.locator("#lease-search").get_by_role("alert")).to_have_count(1) 1367 | 1368 | 1369 | @pytest.mark.parametrize( 1370 | ("family", "by", "q"), 1371 | ( 1372 | (6, "IP Address", "2001:db8::"), 1373 | (6, "Subnet ID", "1"), 1374 | (6, "Hostname", "foo"), 1375 | (6, "DUID", "01:02:03:04:05:06:07:08"), 1376 | (4, "IP Address", "192.0.2.0"), 1377 | (4, "Subnet ID", "1"), 1378 | (4, "Hardware Address", "08:08:08:08:08:08"), 1379 | (4, "Client ID", "18:08:08:08:08:08"), 1380 | (4, "Hostname", "foo"), 1381 | ), 1382 | ) 1383 | def test_lease_search_page_param_without_subnet( 1384 | page: Page, kea: KeaClient, family: Literal[4, 6], by: str, q: str 1385 | ) -> None: 1386 | search_lease(page, family, by, q) 1387 | expect(page).to_have_url(re.compile("by=")) 1388 | page_param = "2001:db8:1::" if family == 6 else "192.0.2.0" 1389 | page.goto(f"{page.url}&page={page_param}") 1390 | expect(page.locator("form.form").get_by_role("alert")).to_contain_text( 1391 | "page is only supported with subnet." 1392 | ) 1393 | 1394 | 1395 | def test_filter_servers_by_tag( 1396 | nb_api: pynetbox.api, 1397 | test_tag: str, 1398 | kea_url: str, 1399 | plugin_base: str, 1400 | page: Page, 1401 | ) -> None: 1402 | nb_api.plugins.kea.servers.create( 1403 | name="tag-test", server_url=kea_url, tags=[{"name": test_tag}] 1404 | ) 1405 | 1406 | page.goto(f"{plugin_base}/servers/") 1407 | page.get_by_role("tab", name="Filters").click() 1408 | page.locator("#id_tag + div.form-select").click() 1409 | page.locator("#id_tag-ts-dropdown").get_by_role( 1410 | "option", name=f"{test_tag} (1)" 1411 | ).click() 1412 | page.get_by_role("button", name=re.compile("Search")).click() 1413 | expect(page.get_by_text("Showing 1-1 of 1")).to_have_count(1) 1414 | 1415 | 1416 | @pytest.mark.parametrize("version", (6, 4)) 1417 | def test_one_service_only( 1418 | page: Page, version: Literal[6, 4], request: pytest.FixtureRequest 1419 | ) -> None: 1420 | request.getfixturevalue(f"with_test_server_only{version}") 1421 | 1422 | server_url = page.url 1423 | pages4 = int(version == 4) 1424 | pages6 = int(version == 6) 1425 | expect(page.get_by_role("link", name="DHCPv4 Leases")).to_have_count(pages4) 1426 | expect(page.get_by_role("link", name="DHCPv4 Subnets")).to_have_count(pages4) 1427 | expect(page.get_by_role("link", name="DHCPv6 Leases")).to_have_count(pages6) 1428 | expect(page.get_by_role("link", name="DHCPv6 Subnets")).to_have_count(pages6) 1429 | 1430 | page.goto(f"{server_url}/leases6/") 1431 | if version == 4: 1432 | expect(page).to_have_url(server_url) 1433 | else: 1434 | expect(page).not_to_have_url(server_url) 1435 | 1436 | page.goto(f"{server_url}/leases4/") 1437 | if version == 6: 1438 | expect(page).to_have_url(server_url) 1439 | else: 1440 | expect(page).not_to_have_url(server_url) 1441 | 1442 | 1443 | @pytest.mark.parametrize("version", (6, 4)) 1444 | def test_lease_to_ip( 1445 | page: Page, 1446 | with_test_server: None, 1447 | request: pytest.FixtureRequest, 1448 | version: Literal[6, 4], 1449 | ) -> None: 1450 | lease_ip: str = request.getfixturevalue(f"lease{version}_netbox_ip") 1451 | 1452 | search_lease(page, version, "IP Address", lease_ip) 1453 | search_lease_related(page, "IPs") 1454 | 1455 | 1456 | @pytest.mark.parametrize("version", (6, 4)) 1457 | def test_lease_to_device( 1458 | page: Page, 1459 | with_test_server: None, 1460 | request: pytest.FixtureRequest, 1461 | version: Literal[6, 4], 1462 | ) -> None: 1463 | lease_ip: str = request.getfixturevalue(f"lease{version}_netbox_device") 1464 | 1465 | server_url = page.url 1466 | 1467 | search_lease(page, version, "IP Address", lease_ip) 1468 | search_lease_related(page, "devices") 1469 | 1470 | page.goto(server_url) 1471 | search_lease(page, version, "IP Address", lease_ip) 1472 | search_lease_related(page, "interfaces") 1473 | 1474 | 1475 | @pytest.mark.parametrize("version", (6, 4)) 1476 | def test_lease_to_vm( 1477 | page: Page, 1478 | with_test_server: None, 1479 | request: pytest.FixtureRequest, 1480 | version: Literal[6, 4], 1481 | ) -> None: 1482 | lease_ip: str = request.getfixturevalue(f"lease{version}_netbox_vm") 1483 | 1484 | server_url = page.url 1485 | 1486 | search_lease(page, version, "IP Address", lease_ip) 1487 | search_lease_related(page, "VMs") 1488 | 1489 | page.goto(server_url) 1490 | search_lease(page, version, "IP Address", lease_ip) 1491 | search_lease_related(page, "VM interfaces") 1492 | 1493 | 1494 | @pytest.mark.parametrize("placement", ("top", "bottom", "both")) 1495 | @pytest.mark.parametrize("version", (6, 4)) 1496 | def test_lease_pagination_location( 1497 | page: Page, 1498 | requests_session: requests.Session, 1499 | nb_api: pynetbox.api, 1500 | with_test_server: None, 1501 | request: pytest.FixtureRequest, 1502 | version: Literal[6, 4], 1503 | placement: Literal["top", "bottom", "both"], 1504 | ) -> None: 1505 | placement = "bottom" 1506 | lease_args = request.getfixturevalue(f"lease{version}") 1507 | ip = lease_args["ip-address"] 1508 | 1509 | # pynetbox doesn't support this endpoint 1510 | requests_session.patch( 1511 | url=f"{nb_api.base_url}/users/config/", 1512 | json={"pagination": {"placement": placement}}, 1513 | ).raise_for_status() 1514 | 1515 | search_lease(page, version, "IP Address", ip) 1516 | 1517 | counts = page.get_by_text(re.compile(r"^Showing \d+ lease\(s\)$")) 1518 | 1519 | if placement == "both": 1520 | expect(counts).to_have_count(2) 1521 | count_y_top = counts.nth(0).bounding_box()["y"] 1522 | count_y_bottom = counts.nth(0).bounding_box()["y"] 1523 | table_y = page.get_by_role("link", name="IP Address").bounding_box()["y"] 1524 | 1525 | assert count_y_top < table_y 1526 | assert count_y_bottom > table_y 1527 | else: 1528 | expect(counts).to_have_count(1) 1529 | count_y = page.get_by_text( 1530 | re.compile(r"^Showing \d+ lease\(s\)$") 1531 | ).bounding_box()["y"] 1532 | table_y = page.get_by_role("link", name="IP Address").bounding_box()["y"] 1533 | 1534 | match placement: 1535 | case "top": 1536 | assert count_y < table_y 1537 | case "bottom": 1538 | assert count_y > table_y 1539 | case _: 1540 | assert False 1541 | 1542 | 1543 | def test_dhcpv6_lease_long_duid( 1544 | page: Page, kea: KeaClient, with_test_server_only6: None 1545 | ) -> None: 1546 | """ 1547 | Regression test for long DUIDs (#154). 1548 | """ 1549 | lease_ip = "2001:db8:1::dead:beef" 1550 | kea.command( 1551 | "lease6-add", 1552 | service=["dhcp6"], 1553 | arguments={ 1554 | "ip-address": lease_ip, 1555 | "duid": "01:02:03:04:05:06:07:08:02:03:04:05:06:07:08:02:03:04:05:06:07:08:02:03:04:05:06:07:08:02:03:04:05:06:07:08:02:03:04:05:06:07:08", 1556 | "hw-address": "08:08:08:08:08:08", 1557 | "iaid": 1, 1558 | "valid-lft": 3600, 1559 | "hostname": "test-lease6", 1560 | "preferred-lft": 7200, 1561 | }, 1562 | ) 1563 | 1564 | def search() -> None: 1565 | search_lease(page, 6, "IP Address", lease_ip) 1566 | 1567 | search() 1568 | configure_table( 1569 | page, 1570 | "ip_address", 1571 | "duid", 1572 | "hostname", 1573 | "hw_address", 1574 | ) 1575 | if _version_ge_43(page): 1576 | search() 1577 | 1578 | # The last column should not be off the page. 1579 | expect(page.locator("table.object-list > tbody > tr > td").last).to_be_in_viewport() 1580 | --------------------------------------------------------------------------------