Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
25f1a21
[feature] Extended Template model for standalone X.509 certificates #…
stktyagi May 26, 2026
a795e09
[fix] Updated and added tests Fixes #1356
stktyagi May 26, 2026
b946d26
[fix] Fixed auto_cert help text
stktyagi May 26, 2026
ff1e8a1
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi May 27, 2026
0053bcf
[fix] Validate cert relations only inside the cert branch #1356
stktyagi May 27, 2026
01221d9
[fix] Added test for cert validation path #1356
stktyagi May 27, 2026
5c551c7
[fix] Fixed flake error #1356
stktyagi May 27, 2026
d6fd193
[feature] Add DeviceCertificate M2M model and cert template support #…
stktyagi May 28, 2026
cae38a5
[fix] Updated test for double checks #1377
stktyagi May 28, 2026
02912a0
[feature] Updated django admin and API #1357 #1361
stktyagi May 29, 2026
3434719
[fix] Fixed tests and improvedd serializers #1357
stktyagi May 29, 2026
298572f
[feature] Implemented certificate lifecycle management #1358
stktyagi May 29, 2026
5c522e9
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi May 30, 2026
052cebf
[tests] Added tests for cert template type #1358
stktyagi May 29, 2026
148bfd2
[tests] Added tests for API #1361
stktyagi May 30, 2026
badf1c1
[fix] Addressed model improvements #1377
stktyagi May 30, 2026
a692c77
[fix] Fixed certificate template reordering lifecycle #1358
stktyagi May 31, 2026
16703f1
[fix] Minor improvements
stktyagi May 31, 2026
212b839
[fix] Made cert creation transactional
stktyagi Jun 1, 2026
37ff9a9
[fix] Fixed NULL handling in get_unassigned_certs()
stktyagi Jun 1, 2026
ef90a6c
[fix] Filtered revoked certs from blueprint list
stktyagi Jun 2, 2026
b8bc3e0
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 2, 2026
88af90a
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 3, 2026
bca3809
[fix] Prevent zombie certs
stktyagi Jun 3, 2026
99cd151
[tests] Cover the partial replacement path too
stktyagi Jun 3, 2026
7390020
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 4, 2026
ee79c93
[chores] Added custom oid extensions for mac and uuid #1377
stktyagi Jun 4, 2026
c92cc5e
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 5, 2026
dfa650c
[docs] Added inital documentation
stktyagi Jun 5, 2026
88003e2
[feature] Added implementation for context injection #1360
stktyagi Jun 6, 2026
b9db466
[tests] Updated tests to pass CI
stktyagi Jun 7, 2026
28f9cce
[fix] Addressed review changes
stktyagi Jun 7, 2026
228e9bc
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 9, 2026
1e9e813
[docs] Added docs for context injection #1360
stktyagi Jun 9, 2026
717c3e1
[feature] Implemented certificate regeneration #1359
stktyagi Jun 12, 2026
7f8dba9
[fix] Improved certificate regeneration
stktyagi Jun 12, 2026
e7a6b16
[fix] Synchronize config status on standalone cert renewal
stktyagi Jun 13, 2026
406e780
[docs] Addressed review comments
stktyagi Jun 13, 2026
25ab4c3
[fix] Made hardware drift cert regeneration idempotent
stktyagi Jun 16, 2026
4711fa3
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 18, 2026
a819721
[chores] Updated DeviceCertificate model and tests
stktyagi Jun 18, 2026
8c32282
[chores] Minor improvements
stktyagi Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions openwisp_controller/config/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ class Media:
css = {"all": (f"{prefix}css/admin.css",)}
js = list(CopyableFieldsAdmin.Media.js) + [
f"{prefix}js/{file_}"
for file_ in ("preview.js", "unsaved_changes.js", "switcher.js")
for file_ in (
"preview.js",
"unsaved_changes.js",
"switcher.js",
"template_ui.js",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]

def get_extra_context(self, pk=None):
Expand Down Expand Up @@ -1059,6 +1064,8 @@ class TemplateAdmin(MultitenantAdminMixin, BaseConfigAdmin, SystemDefinedVariabl
"organization",
"type",
"backend",
"ca",
"blueprint_cert",
"default",
"required",
"created",
Expand All @@ -1073,13 +1080,15 @@ class TemplateAdmin(MultitenantAdminMixin, BaseConfigAdmin, SystemDefinedVariabl
"created",
]
search_fields = ["name"]
multitenant_shared_relations = ("vpn",)
multitenant_shared_relations = ("vpn", "ca", "blueprint_cert")
fields = [
"name",
"organization",
"type",
"backend",
"vpn",
"ca",
"blueprint_cert",
"auto_cert",
"tags",
"default",
Expand All @@ -1091,7 +1100,7 @@ class TemplateAdmin(MultitenantAdminMixin, BaseConfigAdmin, SystemDefinedVariabl
"modified",
]
readonly_fields = ["system_context"]
autocomplete_fields = ["vpn"]
autocomplete_fields = ["vpn", "ca", "blueprint_cert"]

@admin.action(permissions=["add"])
def clone_selected_templates(self, request, queryset):
Expand Down
79 changes: 78 additions & 1 deletion openwisp_controller/config/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
DeviceGroup = load_model("config", "DeviceGroup")
Config = load_model("config", "Config")
Organization = load_model("openwisp_users", "Organization")
DeviceCertificate = load_model("config", "DeviceCertificate")


class BaseMeta:
Expand All @@ -38,6 +39,8 @@ class Meta(BaseMeta):
"type",
"backend",
"vpn",
"ca",
"blueprint_cert",
"tags",
"default",
"required",
Expand All @@ -46,6 +49,16 @@ class Meta(BaseMeta):
"created",
"modified",
]
extra_kwargs = {
"blueprint_cert": {
"error_messages": {
"does_not_exist": _(
"This certificate does not exist or is already "
"assigned to an active device."
)
}
}
}
Comment thread
Aryamanz29 marked this conversation as resolved.

def validate_vpn(self, value):
"""
Expand All @@ -62,12 +75,73 @@ def validate_config(self, value):
"""
Display appropriate field name.
"""
if self.initial_data.get("type") == "generic" and value == {}:
template_type = self.initial_data.get("type")
if template_type == "generic" and value == {}:
raise serializers.ValidationError(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
_("The configuration field cannot be empty.")
)
return value

def validate(self, data):
"""
Explicitly validate certificate template fields and locks for the API.
"""
Comment thread
Aryamanz29 marked this conversation as resolved.
template_type = data.get("type", getattr(self.instance, "type", "generic"))
ca = data.get("ca", getattr(self.instance, "ca", None))
blueprint_cert = data.get(
"blueprint_cert", getattr(self.instance, "blueprint_cert", None)
)
if template_type == "cert" and not ca:
raise serializers.ValidationError(
{
"ca": _(
"A Certificate Authority is required when "
"the template type is certificate."
)
}
)
elif template_type != "cert":
data["ca"] = None
data["blueprint_cert"] = None
if blueprint_cert and ca:
if blueprint_cert.ca_id != ca.id:
raise serializers.ValidationError(
{
"blueprint_cert": _(
"The selected certificate must match "
"the selected Certificate Authority."
)
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if self.instance and self.instance.pk:
if Config.objects.filter(templates=self.instance).exists():

@nemesifier nemesifier Jun 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change

if "ca" in data and data["ca"] != getattr(
Comment thread
stktyagi marked this conversation as resolved.
Outdated
self.instance, "ca_id", self.instance.ca
):
raise serializers.ValidationError(
{
"ca": _(
"This template is already assigned to active devices. "
"You cannot change the CA or Blueprint Certificate "
"on an active template."
)
}
)
if "blueprint_cert" in data and data["blueprint_cert"] != getattr(
self.instance, "blueprint_cert_id", self.instance.blueprint_cert
):
raise serializers.ValidationError(
{
"blueprint_cert": _(
"This template is already assigned to active devices. "
"You cannot change the CA or Blueprint Certificate "
"on an active template."
)
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return super().validate(data)


class VpnSerializer(BaseSerializer):
config = serializers.JSONField(initial={})
Expand Down Expand Up @@ -203,6 +277,9 @@ def _update_config(self, device, config_data):
vpn_list = config.templates.filter(type="vpn").values_list("vpn")
if vpn_list:
config.vpnclient_set.exclude(vpn__in=vpn_list).delete()
DeviceCertificate.objects.filter(config=config).exclude(
template_id__in=config_templates
).delete()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
stktyagi marked this conversation as resolved.
config.templates.set(config_templates, clear=True)
Comment thread
stktyagi marked this conversation as resolved.
config.save()
except ValidationError as error:
Expand Down
8 changes: 8 additions & 0 deletions openwisp_controller/config/base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
from collections import defaultdict

import swapper
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
from cache_memoize import cache_memoize
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
Expand Down Expand Up @@ -63,6 +64,13 @@ class AbstractConfig(ChecksumCacheMixin, BaseConfig):
related_name="vpn_relations",
blank=True,
)
device_certificates = models.ManyToManyField(
swapper.get_model_name("config", "Template"),
through=swapper.get_model_name("config", "DeviceCertificate"),
related_name="config_device_certificates",
blank=True,
verbose_name=_("device certificates"),
)
Comment thread
stktyagi marked this conversation as resolved.
Comment on lines +66 to +72

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please address the through-model lifecycle consistently. Right now the relation is added, but assignments through Config.add_default_templates(), AbstractTemplate._auto_add_to_existing_configs(), controller registration tags, and direct config.templates.add() do not create DeviceCertificate rows. If this PR keeps assignment/removal behavior for device certificates, it should work across all template assignment paths, not only one serializer update path.


STATUS = Choices("modified", "applied", "error", "deactivating", "deactivated")
status = StatusField(
Expand Down
41 changes: 41 additions & 0 deletions openwisp_controller/config/base/device_certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from swapper import get_model_name, load_model


class AbstractDeviceCertificate(models.Model):
config = models.ForeignKey(
get_model_name("config", "Config"), on_delete=models.CASCADE
)
template = models.ForeignKey(
get_model_name("config", "Template"), on_delete=models.CASCADE
)
cert = models.OneToOneField(
get_model_name("django_x509", "Cert"), on_delete=models.CASCADE
)
auto_cert = models.BooleanField(default=False)

class Meta:
abstract = True
unique_together = ("config", "template")
verbose_name = _("Device certificate")
verbose_name_plural = _("Device certificates")

def __str__(self):
return f"{self.config.device.name} - {self.cert.name}"

def clean(self):
Template = load_model("config", "Template")
if (
self.cert_id
and Template.objects.filter(blueprint_cert_id=self.cert_id).exists()
):
raise ValidationError(
{
"cert": _(
"This certificate is currently used as a blueprint "
"by a template and cannot be directly assigned to a device."
)
}
)
102 changes: 98 additions & 4 deletions openwisp_controller/config/base/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@

logger = logging.getLogger(__name__)

TYPE_CHOICES = (("generic", _("Generic")), ("vpn", _("VPN-client")))
TYPE_CHOICES = (
("generic", _("Generic")),
("vpn", _("VPN-client")),
("cert", _("Certificate")),
)


def default_auto_cert():
Expand Down Expand Up @@ -55,6 +59,34 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig):
null=True,
on_delete=models.CASCADE,
)
ca = models.ForeignKey(
get_model_name("django_x509", "Ca"),
on_delete=models.CASCADE,
verbose_name=_("Certificate Authority"),
blank=True,
null=True,
help_text=_(
"The Certificate Authority that will sign certificates generated "
"by this template."
),
)

def get_unassigned_certs():
Cert = load_model("django_x509", "Cert")
return {"pk__in": Cert.objects.exclude(devicecertificate__isnull=False)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

blueprint_cert = models.ForeignKey(
get_model_name("django_x509", "Cert"),
on_delete=models.SET_NULL,
verbose_name=_("Blueprint Certificate"),
blank=True,
null=True,
limit_choices_to=get_unassigned_certs,
help_text=_(
"Optional: Select an unassigned certificate to copy extensions and "
"properties from."
),
)
type = models.CharField(
_("type"),
max_length=16,
Expand Down Expand Up @@ -90,7 +122,8 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig):
help_text=_(
"whether tunnel specific configuration (cryptographic keys, ip addresses, "
"etc) should be automatically generated and managed behind the scenes "
"for each configuration using this template, valid only for the VPN type"
"for each configuration using this template, valid only for the VPN and "
"certificate template types"
),
)
default_values = JSONField(
Expand Down Expand Up @@ -221,6 +254,26 @@ def clean(self, *args, **kwargs):
* automatically determines configuration if necessary
* if flagged as required forces it also to be default
"""
if not self._state.adding:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this clean method is becoming quite big and the docstring was not updated to reflect these new validations: try to concentrate the logic for this new feature in specific methods so it remains as much separated as possible from the exsiting code and do not forget to update docstring

try:
current = self.__class__.objects.get(pk=self.pk)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we need to put the entire block in the try/except or can we leave only the get?

if (
current.ca_id != self.ca_id
or current.blueprint_cert_id != self.blueprint_cert_id
):
Config = load_model("config", "Config")
if Config.objects.filter(templates=self).exists():
raise ValidationError(
{
"ca": _(
"This template is already assigned "
"to active devices. You cannot change the CA "
"or Blueprint Certificate on an active template."
)
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
except self.__class__.DoesNotExist:
pass
self._validate_org_relation("vpn")
if not self.default_values:
self.default_values = {}
Expand All @@ -234,15 +287,56 @@ def clean(self, *args, **kwargs):
)
elif self.type != "vpn":
self.vpn = None
self.auto_cert = False
if self.type != "cert":
self.auto_cert = False
if self.type == "vpn" and not self.config:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
self.config = self.vpn.auto_client(
auto_cert=self.auto_cert, template_backend_class=self.backend_class
)
if self.type == "cert":
self._validate_org_relation("ca")
Comment thread
stktyagi marked this conversation as resolved.
self._validate_org_relation("blueprint_cert")
if not self.ca:
raise ValidationError(
{
"ca": _(
"A Certificate Authority is required when the template "
"type is certificate."
)
}
)
if self.blueprint_cert and self.blueprint_cert.ca_id != self.ca_id:
raise ValidationError(
{
"blueprint_cert": _(
"The selected certificate must match the selected "
"Certificate Authority."
)
}
)
if self.blueprint_cert_id:
DeviceCertificate = load_model("config", "DeviceCertificate")
if DeviceCertificate.objects.filter(
cert_id=self.blueprint_cert_id
).exists():
raise ValidationError(
{
"blueprint_cert": _(
"This certificate is already assigned to a device. "
"Please select an unassigned certificate to "
"use as a blueprint."
)
}
)
if self.config is None:
self.config = {}
else:
self.ca = None
self.blueprint_cert = None
if self.required and not self.default:
self.default = True
super().clean(*args, **kwargs)
if not self.config:
if not self.config and self.type != "cert":
raise ValidationError(_("The configuration field cannot be empty."))

def get_context(self, system=False):
Expand Down
Loading
Loading