netbox-certificates-plugin 0.4.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_certificates/__init__.py +21 -0
- netbox_certificates/api/__init__.py +3 -0
- netbox_certificates/api/serializers.py +385 -0
- netbox_certificates/api/urls.py +16 -0
- netbox_certificates/api/views.py +327 -0
- netbox_certificates/choices.py +124 -0
- netbox_certificates/constants.py +21 -0
- netbox_certificates/dashboard.py +23 -0
- netbox_certificates/filtersets.py +295 -0
- netbox_certificates/forms.py +780 -0
- netbox_certificates/jobs.py +20 -0
- netbox_certificates/management/__init__.py +0 -0
- netbox_certificates/management/commands/__init__.py +0 -0
- netbox_certificates/management/commands/reconcile_certificate_links.py +10 -0
- netbox_certificates/management/commands/refresh_certificate_status.py +10 -0
- netbox_certificates/migrations/0001_initial.py +149 -0
- netbox_certificates/migrations/0002_alter_bundle_options_alter_privatekey_options.py +9 -0
- netbox_certificates/migrations/0003_remove_privatekey_curve_bundle_identity_fingerprint_and_more.py +14 -0
- netbox_certificates/migrations/0004_remove_bundle_protection_password_hash_and_more.py +11 -0
- netbox_certificates/migrations/0005_certificate_supersedes_and_permissions.py +13 -0
- netbox_certificates/migrations/0006_expiry_alerts.py +73 -0
- netbox_certificates/migrations/0007_trigger_field_names.py +14 -0
- netbox_certificates/migrations/0008_expiry_alert_webhook_tls_verification.py +12 -0
- netbox_certificates/migrations/0009_expiry_alert_expired_certificate_preference.py +12 -0
- netbox_certificates/migrations/0010_groups_and_repeat_alerts.py +67 -0
- netbox_certificates/migrations/0011_object_groups_and_certificate_authorities.py +99 -0
- netbox_certificates/migrations/0012_group_hierarchy_and_ui_labels.py +107 -0
- netbox_certificates/migrations/0013_root_authorities_and_bundle_status.py +107 -0
- netbox_certificates/migrations/__init__.py +0 -0
- netbox_certificates/models.py +407 -0
- netbox_certificates/navigation.py +70 -0
- netbox_certificates/permissions.py +44 -0
- netbox_certificates/search.py +44 -0
- netbox_certificates/services/__init__.py +1 -0
- netbox_certificates/services/alerts.py +416 -0
- netbox_certificates/services/bundles.py +253 -0
- netbox_certificates/services/certificate_authorities.py +95 -0
- netbox_certificates/services/chain.py +78 -0
- netbox_certificates/services/csr.py +138 -0
- netbox_certificates/services/duplicates.py +51 -0
- netbox_certificates/services/encryption.py +48 -0
- netbox_certificates/services/expiry.py +60 -0
- netbox_certificates/services/importing.py +136 -0
- netbox_certificates/services/ingest.py +66 -0
- netbox_certificates/services/inventory.py +96 -0
- netbox_certificates/services/linker.py +232 -0
- netbox_certificates/services/parser.py +272 -0
- netbox_certificates/services/pkcs12_export.py +34 -0
- netbox_certificates/services/renewal.py +19 -0
- netbox_certificates/services/status.py +29 -0
- netbox_certificates/services/unified_import.py +147 -0
- netbox_certificates/signals.py +42 -0
- netbox_certificates/static/netbox_certificates/inventory.css +164 -0
- netbox_certificates/tables.py +233 -0
- netbox_certificates/templates/netbox_certificates/_groups.html +13 -0
- netbox_certificates/templates/netbox_certificates/_links.html +20 -0
- netbox_certificates/templates/netbox_certificates/bundle.html +3 -0
- netbox_certificates/templates/netbox_certificates/bundle_export.html +1 -0
- netbox_certificates/templates/netbox_certificates/bundle_list.html +4 -0
- netbox_certificates/templates/netbox_certificates/certificate.html +28 -0
- netbox_certificates/templates/netbox_certificates/certificate_authority.html +38 -0
- netbox_certificates/templates/netbox_certificates/certificate_authority_list.html +1 -0
- netbox_certificates/templates/netbox_certificates/certificate_list.html +4 -0
- netbox_certificates/templates/netbox_certificates/csr.html +3 -0
- netbox_certificates/templates/netbox_certificates/csr_generate.html +241 -0
- netbox_certificates/templates/netbox_certificates/csr_list.html +5 -0
- netbox_certificates/templates/netbox_certificates/dashboard_widget.html +1 -0
- netbox_certificates/templates/netbox_certificates/expiration_alert_email.html +87 -0
- netbox_certificates/templates/netbox_certificates/expiration_alerts.html +136 -0
- netbox_certificates/templates/netbox_certificates/expiration_dashboard.html +1 -0
- netbox_certificates/templates/netbox_certificates/group.html +47 -0
- netbox_certificates/templates/netbox_certificates/group_list.html +1 -0
- netbox_certificates/templates/netbox_certificates/import_objects.html +4 -0
- netbox_certificates/templates/netbox_certificates/inventory.html +90 -0
- netbox_certificates/templates/netbox_certificates/link_add.html +1 -0
- netbox_certificates/templates/netbox_certificates/link_remove.html +1 -0
- netbox_certificates/templates/netbox_certificates/privatekey.html +3 -0
- netbox_certificates/templates/netbox_certificates/privatekey_list.html +4 -0
- netbox_certificates/urls.py +70 -0
- netbox_certificates/views.py +755 -0
- netbox_certificates_plugin-0.4.11.dist-info/METADATA +440 -0
- netbox_certificates_plugin-0.4.11.dist-info/RECORD +86 -0
- netbox_certificates_plugin-0.4.11.dist-info/WHEEL +5 -0
- netbox_certificates_plugin-0.4.11.dist-info/licenses/LICENSE +201 -0
- netbox_certificates_plugin-0.4.11.dist-info/licenses/NOTICE +9 -0
- netbox_certificates_plugin-0.4.11.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NetBoxCertificatesConfig(PluginConfig):
|
|
5
|
+
name = "netbox_certificates"
|
|
6
|
+
verbose_name = "NetBox Certificates Plugin"
|
|
7
|
+
description = "SSL certificate, private key, CSR, bundle, Groups, CA identities, and expiration management"
|
|
8
|
+
version = "0.4.11"
|
|
9
|
+
base_url = "ssl-certificates"
|
|
10
|
+
min_version = "4.5.9"
|
|
11
|
+
max_version = "4.5.10"
|
|
12
|
+
required_settings = ["encryption_key"]
|
|
13
|
+
|
|
14
|
+
def ready(self):
|
|
15
|
+
super().ready()
|
|
16
|
+
from . import signals # noqa: F401
|
|
17
|
+
from . import dashboard # noqa: F401
|
|
18
|
+
from . import jobs # noqa: F401
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
config = NetBoxCertificatesConfig
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from django.db import transaction
|
|
2
|
+
from rest_framework import serializers
|
|
3
|
+
from rest_framework.exceptions import PermissionDenied
|
|
4
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
5
|
+
|
|
6
|
+
from netbox_certificates.choices import LinkOriginChoices
|
|
7
|
+
from netbox_certificates.models import ArtifactGroup, ArtifactLink, Bundle, Certificate, CertificateAuthority, CSR, ExpiryAlertConfiguration, ExpiryAlertEvent, PrivateKey
|
|
8
|
+
from netbox_certificates.permissions import object_allowed
|
|
9
|
+
from netbox_certificates.services.duplicates import find_duplicate
|
|
10
|
+
from netbox_certificates.services.encryption import encrypt_private_key, encrypt_secret
|
|
11
|
+
from netbox_certificates.services.expiry import remaining_days
|
|
12
|
+
from netbox_certificates.services.ingest import after_artifact_save
|
|
13
|
+
from netbox_certificates.services.parser import ArtifactParseError, parse_blob
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require_superuser_write_token(request, purpose):
|
|
17
|
+
auth = getattr(request, "auth", None) if request is not None else None
|
|
18
|
+
user = getattr(request, "user", None) if request is not None else None
|
|
19
|
+
if not getattr(user, "is_superuser", False):
|
|
20
|
+
raise PermissionDenied(f"{purpose} requires a NetBox superuser account.")
|
|
21
|
+
if auth is None or not hasattr(auth, "write_enabled"):
|
|
22
|
+
raise PermissionDenied(f"{purpose} requires NetBox API token authentication.")
|
|
23
|
+
if not auth.write_enabled:
|
|
24
|
+
raise PermissionDenied(f"{purpose} requires a write-enabled NetBox API token.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _single(parsed, kind):
|
|
28
|
+
matches = [p for p in parsed if p.kind == kind]
|
|
29
|
+
if len(matches) != 1 or len(parsed) != 1:
|
|
30
|
+
raise serializers.ValidationError(f"Expected exactly one {kind.replace('_', ' ')} object.")
|
|
31
|
+
return matches[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _group_details(obj, request=None):
|
|
35
|
+
groups = obj.groups.all().order_by("name")
|
|
36
|
+
user = getattr(request, "user", None) if request is not None else None
|
|
37
|
+
if user is not None and hasattr(ArtifactGroup.objects, "restrict"):
|
|
38
|
+
visible_ids = ArtifactGroup.objects.restrict(user, "view").filter(pk__in=groups.values("pk")).values_list("pk", flat=True)
|
|
39
|
+
groups = groups.filter(pk__in=visible_ids)
|
|
40
|
+
return [{"id": group.pk, "name": group.name, "url": group.get_absolute_url()} for group in groups]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ArtifactGroupSerializer(NetBoxModelSerializer):
|
|
44
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:artifactgroup-detail")
|
|
45
|
+
parent = serializers.PrimaryKeyRelatedField(queryset=ArtifactGroup.objects.all(), required=False, allow_null=True)
|
|
46
|
+
children = serializers.SerializerMethodField()
|
|
47
|
+
members = serializers.SerializerMethodField()
|
|
48
|
+
certificates = serializers.PrimaryKeyRelatedField(many=True, queryset=Certificate.objects.all(), required=False)
|
|
49
|
+
private_keys = serializers.PrimaryKeyRelatedField(many=True, queryset=PrivateKey.objects.all(), required=False)
|
|
50
|
+
csrs = serializers.PrimaryKeyRelatedField(many=True, queryset=CSR.objects.all(), required=False)
|
|
51
|
+
bundles = serializers.PrimaryKeyRelatedField(many=True, queryset=Bundle.objects.all(), required=False)
|
|
52
|
+
|
|
53
|
+
class Meta:
|
|
54
|
+
model = ArtifactGroup
|
|
55
|
+
fields = (
|
|
56
|
+
"id", "url", "display", "name", "parent", "children", "members", "owner", "description", "comments",
|
|
57
|
+
"certificates", "private_keys", "csrs", "bundles", "tags", "custom_fields", "created", "last_updated",
|
|
58
|
+
)
|
|
59
|
+
brief_fields = ("id", "url", "display", "name", "parent")
|
|
60
|
+
|
|
61
|
+
def validate(self, attrs):
|
|
62
|
+
attrs = super().validate(attrs)
|
|
63
|
+
parent = attrs.get("parent", getattr(self.instance, "parent", None))
|
|
64
|
+
if self.instance is not None and parent is not None:
|
|
65
|
+
if parent.pk == self.instance.pk or parent.pk in set(self.instance.descendant_ids()):
|
|
66
|
+
raise serializers.ValidationError({"parent": "A Group cannot be nested below itself or one of its descendants."})
|
|
67
|
+
request = self.context.get("request")
|
|
68
|
+
if request is not None and parent is not None and not object_allowed(request.user, parent, "view"):
|
|
69
|
+
raise PermissionDenied("Selecting this parent Group requires view permission on it.")
|
|
70
|
+
return attrs
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _memberships(validated_data):
|
|
74
|
+
return {name: validated_data.pop(name, None) for name in ("certificates", "private_keys", "csrs", "bundles")}
|
|
75
|
+
|
|
76
|
+
def _validate_members(self, memberships):
|
|
77
|
+
request = self.context.get("request")
|
|
78
|
+
if request is None:
|
|
79
|
+
return
|
|
80
|
+
for objects in memberships.values():
|
|
81
|
+
if objects is None:
|
|
82
|
+
continue
|
|
83
|
+
for obj in objects:
|
|
84
|
+
if not object_allowed(request.user, obj, "change"):
|
|
85
|
+
raise PermissionDenied(f"Changing Group membership for {obj} requires change permission on that object.")
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _apply_memberships(group, memberships):
|
|
89
|
+
for name, objects in memberships.items():
|
|
90
|
+
if objects is not None:
|
|
91
|
+
getattr(group, name).set(objects)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _member_item(kind, obj):
|
|
95
|
+
return {"type": kind, "id": obj.pk, "name": str(obj), "url": obj.get_absolute_url()}
|
|
96
|
+
|
|
97
|
+
def _visible(self, obj):
|
|
98
|
+
request = self.context.get("request")
|
|
99
|
+
return request is None or object_allowed(request.user, obj, "view")
|
|
100
|
+
|
|
101
|
+
def get_children(self, obj):
|
|
102
|
+
return [
|
|
103
|
+
self._member_item("group", item)
|
|
104
|
+
for item in obj.children.all().order_by("name")
|
|
105
|
+
if self._visible(item)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def get_members(self, obj):
|
|
109
|
+
result = []
|
|
110
|
+
for kind, queryset in (
|
|
111
|
+
("group", obj.children.all().order_by("name")),
|
|
112
|
+
("bundle", obj.bundles.all().order_by("name")),
|
|
113
|
+
("certificate", obj.certificates.all().order_by("name")),
|
|
114
|
+
("private_key", obj.private_keys.all().order_by("name")),
|
|
115
|
+
("csr", obj.csrs.all().order_by("name")),
|
|
116
|
+
):
|
|
117
|
+
result.extend(self._member_item(kind, item) for item in queryset if self._visible(item))
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
def create(self, validated_data):
|
|
121
|
+
memberships = self._memberships(validated_data)
|
|
122
|
+
self._validate_members(memberships)
|
|
123
|
+
with transaction.atomic():
|
|
124
|
+
group = super().create(validated_data)
|
|
125
|
+
self._apply_memberships(group, memberships)
|
|
126
|
+
return group
|
|
127
|
+
|
|
128
|
+
def update(self, instance, validated_data):
|
|
129
|
+
memberships = self._memberships(validated_data)
|
|
130
|
+
self._validate_members(memberships)
|
|
131
|
+
with transaction.atomic():
|
|
132
|
+
group = super().update(instance, validated_data)
|
|
133
|
+
self._apply_memberships(group, memberships)
|
|
134
|
+
return group
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class CertificateAuthoritySerializer(NetBoxModelSerializer):
|
|
138
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:certificateauthority-detail")
|
|
139
|
+
certificate_count = serializers.SerializerMethodField()
|
|
140
|
+
|
|
141
|
+
class Meta:
|
|
142
|
+
model = CertificateAuthority
|
|
143
|
+
fields = ("id", "url", "display", "name", "certificate_count", "description", "created", "last_updated")
|
|
144
|
+
brief_fields = ("id", "url", "display", "name", "certificate_count")
|
|
145
|
+
read_only_fields = fields
|
|
146
|
+
|
|
147
|
+
def get_certificate_count(self, obj):
|
|
148
|
+
request = self.context.get("request")
|
|
149
|
+
if request is None:
|
|
150
|
+
return obj.certificates.count()
|
|
151
|
+
return Certificate.objects.restrict(request.user, "view").filter(authority=obj).count()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class CertificateSerializer(NetBoxModelSerializer):
|
|
155
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:certificate-detail")
|
|
156
|
+
material = serializers.CharField(required=False, allow_blank=False)
|
|
157
|
+
remaining_days = serializers.SerializerMethodField()
|
|
158
|
+
authority = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
159
|
+
authority_details = serializers.SerializerMethodField()
|
|
160
|
+
groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
|
|
161
|
+
group_details = serializers.SerializerMethodField()
|
|
162
|
+
|
|
163
|
+
class Meta:
|
|
164
|
+
model = Certificate
|
|
165
|
+
fields = (
|
|
166
|
+
"id", "url", "display", "name", "status", "source_filename", "source_format", "material",
|
|
167
|
+
"fingerprint_sha256", "public_key_fingerprint", "serial_number", "subject", "issuer", "authority", "authority_details",
|
|
168
|
+
"subject_alternative_names", "valid_from", "valid_to", "signature_algorithm", "key_type",
|
|
169
|
+
"key_size", "curve", "is_ca", "parent_certificate", "supersedes", "remaining_days",
|
|
170
|
+
"alert_trigger", "trigger_unit", "owner", "groups", "group_details", "description", "comments",
|
|
171
|
+
"tags", "custom_fields", "created", "last_updated",
|
|
172
|
+
)
|
|
173
|
+
brief_fields = ("id", "url", "display", "name", "status", "valid_to", "remaining_days", "groups")
|
|
174
|
+
read_only_fields = (
|
|
175
|
+
"status", "source_format", "fingerprint_sha256", "public_key_fingerprint", "serial_number", "subject",
|
|
176
|
+
"issuer", "authority", "authority_details", "subject_alternative_names", "valid_from", "valid_to", "signature_algorithm", "key_type",
|
|
177
|
+
"key_size", "curve", "is_ca", "parent_certificate", "remaining_days", "group_details",
|
|
178
|
+
)
|
|
179
|
+
def get_remaining_days(self, obj): return remaining_days(obj)
|
|
180
|
+
def get_authority_details(self, obj):
|
|
181
|
+
if obj.authority is None:
|
|
182
|
+
return None
|
|
183
|
+
return {"id": obj.authority.pk, "name": obj.authority.name, "url": obj.authority.get_absolute_url()}
|
|
184
|
+
def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
|
|
185
|
+
def validate(self, attrs):
|
|
186
|
+
material = attrs.pop("material", None)
|
|
187
|
+
if self.instance is None and not material:
|
|
188
|
+
raise serializers.ValidationError({"material": "Certificate material is required."})
|
|
189
|
+
if material:
|
|
190
|
+
try: parsed = _single(parse_blob(material.encode(), filename=attrs.get("source_filename") or "certificate.pem"), "certificate")
|
|
191
|
+
except ArtifactParseError as exc: raise serializers.ValidationError({"material": str(exc)}) from exc
|
|
192
|
+
duplicate = find_duplicate("certificate", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
|
|
193
|
+
if duplicate: raise serializers.ValidationError({"material": duplicate.message()})
|
|
194
|
+
self._parsed = parsed
|
|
195
|
+
attrs = self._prepare(attrs)
|
|
196
|
+
return super().validate(attrs)
|
|
197
|
+
def _prepare(self, data):
|
|
198
|
+
parsed = getattr(self, "_parsed", None)
|
|
199
|
+
if parsed:
|
|
200
|
+
data["material"] = parsed.data.decode("ascii"); data.update(parsed.metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
|
|
201
|
+
return data
|
|
202
|
+
def create(self, validated_data):
|
|
203
|
+
with transaction.atomic():
|
|
204
|
+
obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
205
|
+
def update(self, instance, validated_data):
|
|
206
|
+
with transaction.atomic():
|
|
207
|
+
obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class PrivateKeySerializer(NetBoxModelSerializer):
|
|
211
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:privatekey-detail")
|
|
212
|
+
key_material = serializers.CharField(write_only=True, required=False, allow_blank=False)
|
|
213
|
+
input_password = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
|
214
|
+
groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
|
|
215
|
+
group_details = serializers.SerializerMethodField()
|
|
216
|
+
class Meta:
|
|
217
|
+
model = PrivateKey
|
|
218
|
+
fields = ("id", "url", "display", "name", "source_filename", "source_format", "key_material", "input_password", "material_sha256", "public_key_fingerprint", "key_type", "key_size", "encrypted_on_import", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
|
|
219
|
+
brief_fields = ("id", "url", "display", "name", "key_type", "public_key_fingerprint", "groups")
|
|
220
|
+
read_only_fields = ("source_format", "material_sha256", "public_key_fingerprint", "key_type", "key_size", "encrypted_on_import", "group_details")
|
|
221
|
+
def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
|
|
222
|
+
def validate(self, attrs):
|
|
223
|
+
material = attrs.pop("key_material", None); password = attrs.pop("input_password", None) or None; attrs = super().validate(attrs)
|
|
224
|
+
if self.instance is None and not material: raise serializers.ValidationError({"key_material": "Private key material is required."})
|
|
225
|
+
if material:
|
|
226
|
+
_require_superuser_write_token(self.context.get("request"), "Private-key material API access")
|
|
227
|
+
try: parsed = _single(parse_blob(material.encode(), password=password, filename=attrs.get("source_filename") or "private.key"), "private_key")
|
|
228
|
+
except ArtifactParseError as exc: raise serializers.ValidationError({"key_material": str(exc)}) from exc
|
|
229
|
+
duplicate = find_duplicate("private_key", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
|
|
230
|
+
if duplicate: raise serializers.ValidationError({"key_material": duplicate.message()})
|
|
231
|
+
self._parsed = parsed
|
|
232
|
+
return attrs
|
|
233
|
+
def _prepare(self, data):
|
|
234
|
+
parsed = getattr(self, "_parsed", None)
|
|
235
|
+
if parsed:
|
|
236
|
+
data["encrypted_material"] = encrypt_private_key(parsed.data); metadata = parsed.metadata.copy(); metadata.pop("curve", None); data.update(metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
|
|
237
|
+
return data
|
|
238
|
+
def create(self, validated_data):
|
|
239
|
+
with transaction.atomic(): obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
240
|
+
def update(self, instance, validated_data):
|
|
241
|
+
with transaction.atomic(): obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class CSRSerializer(NetBoxModelSerializer):
|
|
245
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:csr-detail")
|
|
246
|
+
material = serializers.CharField(required=False, allow_blank=False)
|
|
247
|
+
groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
|
|
248
|
+
group_details = serializers.SerializerMethodField()
|
|
249
|
+
class Meta:
|
|
250
|
+
model = CSR
|
|
251
|
+
fields = ("id", "url", "display", "name", "source_filename", "source_format", "material", "fingerprint_sha256", "public_key_fingerprint", "subject", "subject_alternative_names", "signature_algorithm", "key_type", "key_size", "curve", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
|
|
252
|
+
brief_fields = ("id", "url", "display", "name", "subject", "public_key_fingerprint", "groups")
|
|
253
|
+
read_only_fields = ("source_format", "fingerprint_sha256", "public_key_fingerprint", "subject", "subject_alternative_names", "signature_algorithm", "key_type", "key_size", "curve", "group_details")
|
|
254
|
+
def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
|
|
255
|
+
def validate(self, attrs):
|
|
256
|
+
material = attrs.pop("material", None)
|
|
257
|
+
if self.instance is None and not material: raise serializers.ValidationError({"material": "CSR material is required."})
|
|
258
|
+
if material:
|
|
259
|
+
try: parsed = _single(parse_blob(material.encode(), filename=attrs.get("source_filename") or "request.csr"), "csr")
|
|
260
|
+
except ArtifactParseError as exc: raise serializers.ValidationError({"material": str(exc)}) from exc
|
|
261
|
+
duplicate = find_duplicate("csr", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
|
|
262
|
+
if duplicate: raise serializers.ValidationError({"material": duplicate.message()})
|
|
263
|
+
self._parsed = parsed
|
|
264
|
+
attrs = self._prepare(attrs)
|
|
265
|
+
return super().validate(attrs)
|
|
266
|
+
def _prepare(self, data):
|
|
267
|
+
parsed = getattr(self, "_parsed", None)
|
|
268
|
+
if parsed: data["material"] = parsed.data.decode("ascii"); data.update(parsed.metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
|
|
269
|
+
return data
|
|
270
|
+
def create(self, validated_data):
|
|
271
|
+
with transaction.atomic(): obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
272
|
+
def update(self, instance, validated_data):
|
|
273
|
+
with transaction.atomic(): obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class BundleSerializer(NetBoxModelSerializer):
|
|
277
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:bundle-detail")
|
|
278
|
+
certificate = serializers.SerializerMethodField(); private_key = serializers.SerializerMethodField(); csr = serializers.SerializerMethodField(); chain_certificates = serializers.SerializerMethodField()
|
|
279
|
+
groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
|
|
280
|
+
group_details = serializers.SerializerMethodField()
|
|
281
|
+
class Meta:
|
|
282
|
+
model = Bundle
|
|
283
|
+
fields = ("id", "url", "display", "name", "identity_fingerprint", "source_filename", "archive_format", "status", "certificate", "private_key", "csr", "chain_certificates", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
|
|
284
|
+
brief_fields = ("id", "url", "display", "name", "status", "groups")
|
|
285
|
+
read_only_fields = ("name", "identity_fingerprint", "source_filename", "archive_format", "status", "certificate", "private_key", "csr", "chain_certificates", "group_details")
|
|
286
|
+
def _visible(self, obj):
|
|
287
|
+
if obj is None: return None
|
|
288
|
+
request = self.context.get("request")
|
|
289
|
+
if request is None: return obj
|
|
290
|
+
return obj if obj.__class__.objects.restrict(request.user, "view").filter(pk=obj.pk).exists() else None
|
|
291
|
+
def get_certificate(self, obj):
|
|
292
|
+
value = self._visible(obj.certificate); return CertificateSerializer(value, context=self.context).data if value else None
|
|
293
|
+
def get_private_key(self, obj):
|
|
294
|
+
value = self._visible(obj.private_key); return PrivateKeySerializer(value, context=self.context).data if value else None
|
|
295
|
+
def get_csr(self, obj):
|
|
296
|
+
value = self._visible(obj.csr); return CSRSerializer(value, context=self.context).data if value else None
|
|
297
|
+
def get_chain_certificates(self, obj):
|
|
298
|
+
request = self.context.get("request"); qs = obj.chain_certificates.all(); qs = qs.restrict(request.user, "view") if request is not None else qs
|
|
299
|
+
return CertificateSerializer(qs, many=True, context=self.context).data
|
|
300
|
+
def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class ArtifactLinkSerializer(NetBoxModelSerializer):
|
|
304
|
+
display = serializers.SerializerMethodField()
|
|
305
|
+
class Meta:
|
|
306
|
+
model = ArtifactLink
|
|
307
|
+
fields = ("id", "display", "source_type", "source_id", "target_type", "target_id", "relation", "origin", "active", "note", "created", "last_updated")
|
|
308
|
+
read_only_fields = ("origin", "created", "last_updated")
|
|
309
|
+
def get_display(self, obj): return f"Artifact link #{obj.pk}"
|
|
310
|
+
def _resolve_visible_endpoint(self, object_type, object_id, field):
|
|
311
|
+
model = object_type.model_class() if object_type else None; manager = getattr(model, "objects", None) if model else None; request = self.context.get("request")
|
|
312
|
+
if model is None or manager is None: raise serializers.ValidationError({field: "The selected object type cannot be linked."})
|
|
313
|
+
qs = manager.all(); qs = qs.restrict(request.user, "view") if request is not None and hasattr(qs, "restrict") else qs
|
|
314
|
+
try: return qs.get(pk=object_id)
|
|
315
|
+
except model.DoesNotExist: raise serializers.ValidationError({field: "The selected object does not exist or is not visible."})
|
|
316
|
+
def validate(self, attrs):
|
|
317
|
+
attrs = super().validate(attrs)
|
|
318
|
+
source_type = attrs.get("source_type", getattr(self.instance, "source_type", None)); source_id = attrs.get("source_id", getattr(self.instance, "source_id", None)); target_type = attrs.get("target_type", getattr(self.instance, "target_type", None)); target_id = attrs.get("target_id", getattr(self.instance, "target_id", None))
|
|
319
|
+
source = self._resolve_visible_endpoint(source_type, source_id, "source_id"); target = self._resolve_visible_endpoint(target_type, target_id, "target_id")
|
|
320
|
+
if not isinstance(source, (Certificate, PrivateKey, CSR, Bundle)) and not isinstance(target, (Certificate, PrivateKey, CSR, Bundle)):
|
|
321
|
+
raise serializers.ValidationError("At least one endpoint must be a NetBox Certificates object.")
|
|
322
|
+
if source_type.pk == target_type.pk and source_id == target_id: raise serializers.ValidationError("An object cannot be linked to itself.")
|
|
323
|
+
return attrs
|
|
324
|
+
def create(self, validated_data): validated_data["origin"] = LinkOriginChoices.MANUAL; return super().create(validated_data)
|
|
325
|
+
def update(self, instance, validated_data):
|
|
326
|
+
if instance.origin != LinkOriginChoices.MANUAL: raise serializers.ValidationError("Automatically generated links cannot be modified through the API.")
|
|
327
|
+
validated_data["origin"] = LinkOriginChoices.MANUAL; return super().update(instance, validated_data)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class ExpiryAlertConfigurationSerializer(NetBoxModelSerializer):
|
|
331
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:expiration-alert-configuration-detail")
|
|
332
|
+
smtp_password = serializers.CharField(write_only=True, required=False, allow_blank=True, trim_whitespace=False)
|
|
333
|
+
webhook_url = serializers.URLField(write_only=True, required=False, allow_blank=True)
|
|
334
|
+
webhook_bearer_token = serializers.CharField(write_only=True, required=False, allow_blank=True, trim_whitespace=False)
|
|
335
|
+
smtp_password_configured = serializers.SerializerMethodField(); webhook_url_configured = serializers.SerializerMethodField(); webhook_bearer_token_configured = serializers.SerializerMethodField()
|
|
336
|
+
class Meta:
|
|
337
|
+
model = ExpiryAlertConfiguration
|
|
338
|
+
fields = (
|
|
339
|
+
"id", "url", "display", "check_interval_minutes", "alert_on_expired_certificates", "alert_repeat_mode",
|
|
340
|
+
"email_enabled", "smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_password_configured", "smtp_use_tls", "smtp_use_ssl", "email_from_address", "email_recipients", "include_superusers",
|
|
341
|
+
"webhook_enabled", "webhook_url", "webhook_url_configured", "webhook_bearer_token", "webhook_bearer_token_configured", "webhook_allow_http", "webhook_ignore_tls_verification",
|
|
342
|
+
"last_check_at", "last_check_success", "last_check_message", "email_last_test_at", "email_last_test_success", "email_last_test_message", "webhook_last_test_at", "webhook_last_test_success", "webhook_last_test_message", "custom_fields", "created", "last_updated",
|
|
343
|
+
)
|
|
344
|
+
brief_fields = ("id", "url", "display", "alert_on_expired_certificates", "alert_repeat_mode", "email_enabled", "webhook_enabled", "last_check_at", "last_check_success")
|
|
345
|
+
read_only_fields = ("last_check_at", "last_check_success", "last_check_message", "email_last_test_at", "email_last_test_success", "email_last_test_message", "webhook_last_test_at", "webhook_last_test_success", "webhook_last_test_message")
|
|
346
|
+
def get_smtp_password_configured(self, obj): return bool(obj.smtp_password_encrypted)
|
|
347
|
+
def get_webhook_url_configured(self, obj): return bool(obj.webhook_url_encrypted)
|
|
348
|
+
def get_webhook_bearer_token_configured(self, obj): return bool(obj.webhook_bearer_token_encrypted)
|
|
349
|
+
def validate(self, attrs):
|
|
350
|
+
smtp_password = attrs.pop("smtp_password", None); webhook_url = attrs.pop("webhook_url", None); webhook_token = attrs.pop("webhook_bearer_token", None)
|
|
351
|
+
if smtp_password or webhook_token: _require_superuser_write_token(self.context.get("request"), "Alert secret API access")
|
|
352
|
+
attrs = super().validate(attrs)
|
|
353
|
+
tls = attrs.get("smtp_use_tls", getattr(self.instance, "smtp_use_tls", True)); ssl = attrs.get("smtp_use_ssl", getattr(self.instance, "smtp_use_ssl", False))
|
|
354
|
+
if tls and ssl: raise serializers.ValidationError({"smtp_use_ssl": "TLS and implicit SSL cannot both be enabled."})
|
|
355
|
+
email_enabled = attrs.get("email_enabled", getattr(self.instance, "email_enabled", False))
|
|
356
|
+
if email_enabled:
|
|
357
|
+
host = attrs.get("smtp_host", getattr(self.instance, "smtp_host", "")); port = attrs.get("smtp_port", getattr(self.instance, "smtp_port", None)); sender = attrs.get("email_from_address", getattr(self.instance, "email_from_address", "")); include_superusers = attrs.get("include_superusers", getattr(self.instance, "include_superusers", True)); recipients = attrs.get("email_recipients", getattr(self.instance, "email_recipients", "")); errors = {}
|
|
358
|
+
if not host: errors["smtp_host"] = "SMTP host is required when email alerts are enabled."
|
|
359
|
+
if not port: errors["smtp_port"] = "SMTP port is required when email alerts are enabled."
|
|
360
|
+
if not sender: errors["email_from_address"] = "From address is required when email alerts are enabled."
|
|
361
|
+
if not include_superusers and not str(recipients).strip(): errors["email_recipients"] = "Configure a recipient or enable NetBox superuser recipients."
|
|
362
|
+
if errors: raise serializers.ValidationError(errors)
|
|
363
|
+
webhook_enabled = attrs.get("webhook_enabled", getattr(self.instance, "webhook_enabled", False)); existing_webhook = bool(getattr(self.instance, "webhook_url_encrypted", None)); allow_http = attrs.get("webhook_allow_http", getattr(self.instance, "webhook_allow_http", False))
|
|
364
|
+
if webhook_enabled and not webhook_url and not existing_webhook: raise serializers.ValidationError({"webhook_url": "Webhook URL is required when webhook alerts are enabled."})
|
|
365
|
+
if webhook_url and webhook_url.lower().startswith("http://") and not allow_http: raise serializers.ValidationError({"webhook_url": "HTTP webhooks require webhook_allow_http=true."})
|
|
366
|
+
self._smtp_password, self._webhook_url, self._webhook_token = smtp_password, webhook_url, webhook_token
|
|
367
|
+
return attrs
|
|
368
|
+
def _apply_secrets(self, data):
|
|
369
|
+
if getattr(self, "_smtp_password", None): data["smtp_password_encrypted"] = encrypt_secret(self._smtp_password)
|
|
370
|
+
if getattr(self, "_webhook_url", None): data["webhook_url_encrypted"] = encrypt_secret(self._webhook_url)
|
|
371
|
+
if getattr(self, "_webhook_token", None): data["webhook_bearer_token_encrypted"] = encrypt_secret(self._webhook_token)
|
|
372
|
+
return data
|
|
373
|
+
def create(self, validated_data):
|
|
374
|
+
if ExpiryAlertConfiguration.objects.exists(): raise serializers.ValidationError("Only one expiration alert configuration can exist.")
|
|
375
|
+
return super().create(self._apply_secrets(validated_data))
|
|
376
|
+
def update(self, instance, validated_data): return super().update(instance, self._apply_secrets(validated_data))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ExpiryAlertEventSerializer(NetBoxModelSerializer):
|
|
380
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:expiration-alert-event-detail")
|
|
381
|
+
class Meta:
|
|
382
|
+
model = ExpiryAlertEvent
|
|
383
|
+
fields = ("id", "url", "display", "certificate", "method", "certificate_valid_to", "trigger_unit", "alert_trigger", "trigger_at", "last_attempt_at", "delivered_at", "success", "attempt_count", "status_code", "message", "custom_fields", "created", "last_updated")
|
|
384
|
+
brief_fields = ("id", "url", "display", "certificate", "method", "success", "last_attempt_at", "attempt_count")
|
|
385
|
+
read_only_fields = fields
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
from netbox.api.routers import NetBoxRouter
|
|
3
|
+
from .views import ArtifactGroupViewSet, ArtifactLinkViewSet, BundleViewSet, CertificateAuthorityViewSet, CertificateViewSet, CSRViewSet, ExpiryAlertConfigurationViewSet, ExpiryAlertEventViewSet, PrivateKeyViewSet, UnifiedImportAPIView
|
|
4
|
+
|
|
5
|
+
app_name = "netbox_certificates"
|
|
6
|
+
router = NetBoxRouter()
|
|
7
|
+
router.register("certificates", CertificateViewSet)
|
|
8
|
+
router.register("certificate-authorities", CertificateAuthorityViewSet)
|
|
9
|
+
router.register("private-keys", PrivateKeyViewSet)
|
|
10
|
+
router.register("csrs", CSRViewSet)
|
|
11
|
+
router.register("bundles", BundleViewSet)
|
|
12
|
+
router.register("groups", ArtifactGroupViewSet)
|
|
13
|
+
router.register("artifact-links", ArtifactLinkViewSet, basename="artifact-link")
|
|
14
|
+
router.register("expiration-alert-configurations", ExpiryAlertConfigurationViewSet, basename="expiration-alert-configuration")
|
|
15
|
+
router.register("expiration-alert-events", ExpiryAlertEventViewSet, basename="expiration-alert-event")
|
|
16
|
+
urlpatterns = router.urls + [path("import-objects/", UnifiedImportAPIView.as_view(), name="import-objects")]
|