Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
84 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
fb54051
[tests] Improved coverage
stktyagi Jun 22, 2026
b52fe63
[fix] Added missing organizational_unit_name
stktyagi Jun 23, 2026
0685dc5
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jun 23, 2026
b486eb5
[docs] Fixed image path
stktyagi Jun 23, 2026
52c7544
[fix] Added certificate details in device admin #1357
stktyagi Jun 24, 2026
b9a9d2a
[fix] Fixed failing tests
stktyagi Jun 24, 2026
8efc405
[fix] Fixed failing tests
stktyagi Jun 25, 2026
8b3fb40
[chores] Minor enhancements
stktyagi Jun 29, 2026
01a556c
[fix] Removed unnecessary conditional
stktyagi Jun 29, 2026
147c4b5
[docs] Added missing documentation
stktyagi Jun 29, 2026
4ded312
[chores] Minor improvements
stktyagi Jun 29, 2026
063c292
[chores] Switched certificate detials location
stktyagi Jun 30, 2026
90c7415
[tests] Fixed tests
stktyagi Jun 30, 2026
1c6d532
Restore workflow file to match remote
stktyagi Jul 2, 2026
0925720
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jul 2, 2026
7bab6fe
[chores] Merge migration for config app
stktyagi Jul 2, 2026
d66b067
[chores] Merge migration for config app
stktyagi Jul 2, 2026
2793599
[chores] Added separate tab for certificate details
stktyagi Jul 3, 2026
74a1e8f
[fix] Fixed test
stktyagi Jul 3, 2026
73b3283
[chores] Addressed coderabbit reviews
stktyagi Jul 7, 2026
bd68792
[fix] Fixed redundant exception tuple
stktyagi Jul 7, 2026
53494cd
[fix] Modified certificate details ui
stktyagi Jul 10, 2026
c12aee9
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jul 10, 2026
375844d
[fix] Eliminated extra query in hardware drift detection
stktyagi Jul 10, 2026
1d117a2
[fix] Refactored ca definition in tests
stktyagi Jul 10, 2026
8c6e35a
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jul 16, 2026
85c865d
[fix] Fixed tests after merge
stktyagi Jul 16, 2026
dda2865
[fix] Fixed formatting
stktyagi Jul 16, 2026
0a9c7d1
[chores] Addressed latest reviews
stktyagi Jul 19, 2026
98c191b
[fix] Fixed assertion
stktyagi Jul 19, 2026
bf08a9c
[fix] Fixed assertion
stktyagi Jul 19, 2026
c803d75
[fix] Fixed migrations
stktyagi Jul 19, 2026
6988562
[fix] Addressed UI reviews
stktyagi Jul 19, 2026
db8c592
[fix] Fixed docs after build
stktyagi Jul 21, 2026
04bfa80
[fix] Addressed coderabbit comments
stktyagi Jul 21, 2026
1df332c
[chores] Linked template and new tab for links
stktyagi Jul 22, 2026
217e574
[chores] Improved X.509 Certs section in device admin
nemesifier Jul 22, 2026
3b2a661
[change] Added device filter to x509, link to it, focused x509 admin
nemesifier Jul 22, 2026
5fc22c3
[tests:fix] Fixed test technical debt
nemesifier Jul 22, 2026
c62c583
[chores] Addressed review comments
stktyagi Jul 30, 2026
164a97b
Merge branch 'gsoc26-x509-certificate-generator-templates' into issue…
stktyagi Jul 30, 2026
83134fc
[fix] Fixed migrations after rebase
stktyagi Jul 30, 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
75 changes: 71 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")),

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.

shouldn't we call this "Certificate generator" ?
I think "Certificate" here is too vague and bad for UX as it's confusing and I also think the same for the docs text which is not using the "Certificate generator" wording nor any equivalent meaning. We must indicate that these templates automate the creation of X509 certificates.

)


def default_auto_cert():
Expand Down Expand Up @@ -55,6 +59,28 @@ 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."
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
blueprint_cert = models.ForeignKey(

@nemesifier nemesifier Jul 22, 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.

SET_NULL allows the blueprint of an active template to be deleted without running Template.clean(). Existing devices keep certificates cloned from the old blueprint while later assignments silently fall back to CA defaults, which is exactly the inconsistency the active mutation lock is intended to prevent. Restrict direct deletion while a blueprint is referenced by an active template. RESTRICT may preserve the intended full CA cascade while preventing this direct deletion path. Cover active and unassigned templates.

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.

SET_NULL allows the blueprint of an active template to be deleted without running Template.clean(). Existing devices keep certificates cloned from the old blueprint while later assignments silently fall back to CA defaults, which is exactly the inconsistency the active mutation lock is intended to prevent. Restrict direct deletion while a blueprint is referenced by an active template. RESTRICT may preserve the intended full CA cascade while preventing this direct deletion path. Cover active and unassigned templates.

get_model_name("django_x509", "Cert"),
on_delete=models.SET_NULL,
verbose_name=_("Blueprint Certificate"),
blank=True,
null=True,
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 +116,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 @@ -234,15 +261,55 @@ 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 and hasattr(
self.blueprint_cert, "devicecertificate_set"
):
if self.blueprint_cert.devicecertificate_set.exists():
raise ValidationError(
{
"blueprint_cert": _(
"This certificate is already assigned to a device. "
"Please select an unassigned certificate to use as a "
"blueprint."
)
}
)
Comment thread
stktyagi marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Generated by Django 5.2.14 on 2026-05-26 20:33

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

import openwisp_controller.config.base.template


class Migration(migrations.Migration):

dependencies = [
("config", "0063_replace_jsonfield_with_django_builtin"),
migrations.swappable_dependency(settings.DJANGO_X509_CA_MODEL),
migrations.swappable_dependency(settings.DJANGO_X509_CERT_MODEL),
]

operations = [
migrations.AddField(
model_name="template",
name="blueprint_cert",
field=models.ForeignKey(
blank=True,
help_text="Optional: Select an unassigned certificate "
"to copy extensions and properties from.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.DJANGO_X509_CERT_MODEL,
verbose_name="Blueprint Certificate",
),
),
migrations.AddField(
model_name="template",
name="ca",
field=models.ForeignKey(
blank=True,
help_text="The Certificate Authority that will sign "
"certificates generated by this template.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.DJANGO_X509_CA_MODEL,
verbose_name="Certificate Authority",
),
),
migrations.AlterField(
model_name="template",
name="auto_cert",
field=models.BooleanField(
db_index=True,
default=openwisp_controller.config.base.template.default_auto_cert,
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 and certificate template types",
verbose_name="automatic tunnel provisioning",
),
),
migrations.AlterField(
model_name="template",
name="type",
field=models.CharField(
choices=[
("generic", "Generic"),
("vpn", "VPN-client"),
("cert", "Certificate"),
],
db_index=True,
default="generic",
help_text="template type, determines which features are available",
max_length=16,
verbose_name="type",
),
),
]
116 changes: 116 additions & 0 deletions openwisp_controller/config/tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,3 +967,119 @@ def test_auto_add_template_to_existing_configs_not_triggered(self, mocked_task):
required_template.full_clean()
required_template.save()
mocked_task.assert_not_called()


class TestTemplateCertificates(CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase):
"""
tests for standalone X.509 certificate Template configurations
"""

def test_cert_template_requires_ca(self):
"""Test that creating a template with type='cert' requires a CA."""
try:
self._create_template(type="cert", config={})
except ValidationError as err:
self.assertIn("ca", err.message_dict)
self.assertIn("required", str(err.message_dict["ca"][0]))
else:
self.fail("ValidationError not raised for missing CA")

def test_blueprint_must_match_ca(self):
"""Test that blueprint_cert must match the selected CA."""
org = self._get_org()
ca_main = self._create_ca(
name="Main CA", common_name="Main CA", organization=org
)
ca_other = self._create_ca(
name="Other CA", common_name="Other CA", organization=org
)
blueprint = self._create_cert(
name="Master Blueprint", ca=ca_main, organization=org
)
try:
self._create_template(
type="cert",
ca=ca_other,
blueprint_cert=blueprint,
organization=org,
config={},
)
except ValidationError as err:
self.assertIn("blueprint_cert", err.message_dict)
self.assertIn("match", str(err.message_dict["blueprint_cert"][0]))
else:
self.fail("ValidationError not raised for CA mismatch")

def test_blueprint_cannot_be_already_assigned(self):
"""Test that blueprint_cert cannot be already assigned to a device."""
org = self._get_org()
ca = self._create_ca(organization=org)
blueprint = self._create_cert(ca=ca, organization=org)
mock_manager = mock.Mock()
mock_manager.exists.return_value = True
blueprint.devicecertificate_set = mock_manager
try:
self._create_template(
type="cert",
ca=ca,
blueprint_cert=blueprint,
organization=org,
config={},
)
except ValidationError as err:
self.assertIn("blueprint_cert", err.message_dict)
self.assertIn(
"already assigned", str(err.message_dict["blueprint_cert"][0])
)
else:
self.fail("ValidationError not raised for assigned blueprint")
finally:
del blueprint.devicecertificate_set

def test_non_cert_clears_fields(self):
"""Test that non-cert template types clear ca and blueprint_cert fields."""
org = self._get_org()
ca = self._create_ca(organization=org)
blueprint = self._create_cert(ca=ca, organization=org)
t = self._create_template(
type="cert",
ca=ca,
blueprint_cert=blueprint,
organization=org,
config={},
)
t.type = "generic"
t.backend = "netjsonconfig.OpenWrt"
t.config = {"interfaces": [{"name": "eth0", "type": "ethernet"}]}
t.full_clean()
self.assertIsNone(t.ca)
self.assertIsNone(t.blueprint_cert)

def test_cert_template_allows_empty_config(self):
"""Test that cert templates can have empty config (unlike other types)."""
ca = self._create_ca()
t = self._create_template(
type="cert",
ca=ca,
config={},
)
self.assertEqual(t.config, {})

def test_organization_validation_for_relations(self):
"""Test organization validation for ca field."""
org1 = self._get_org()
org2 = self._create_org(name="Org2", slug="org2")
ca_org2 = self._create_ca(organization=org2)

try:
self._create_template(
type="cert",
ca=ca_org2,
organization=org1,
config={},
)
except ValidationError as err:
self.assertIn("organization", err.message_dict)
self.assertIn("related CA match", str(err.message_dict["organization"][0]))
else:
self.fail("ValidationError not raised for cross-organization CA relation")
4 changes: 2 additions & 2 deletions openwisp_controller/pki/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def test_crl_download_api(self):
def test_ca_delete_api(self):
ca1 = self._create_ca(name="ca1", organization=self._get_org())
path = reverse("pki_api:ca_detail", args=[ca1.pk])
with self.assertNumQueries(5):
with self.assertNumQueries(6):
Comment thread
stktyagi marked this conversation as resolved.
r = self.client.delete(path)
self.assertEqual(r.status_code, 204)
self.assertEqual(Ca.objects.count(), 0)
Expand Down Expand Up @@ -272,7 +272,7 @@ def test_cert_patch_api(self):
def test_cert_delete_api(self):
cert1 = self._create_cert(name="cert1")
path = reverse("pki_api:cert_detail", args=[cert1.pk])
with self.assertNumQueries(6):
with self.assertNumQueries(7):
r = self.client.delete(path)
self.assertEqual(r.status_code, 204)
self.assertEqual(Cert.objects.count(), 0)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Generated by Django 5.2.14 on 2026-05-26 20:33

import django.db.models.deletion
from django.db import migrations, models

import openwisp_controller.config.base.template


class Migration(migrations.Migration):

dependencies = [
("sample_config", "0009_replace_jsonfield_with_django_builtin"),
("sample_pki", "0004_alter_ca_extensions_alter_ca_key_length_and_more"),
]

operations = [
migrations.AddField(
model_name="template",
name="blueprint_cert",
field=models.ForeignKey(
blank=True,
help_text="Optional: Select an unassigned certificate "
"to copy extensions and properties from.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="sample_pki.cert",
verbose_name="Blueprint Certificate",
),
),
migrations.AddField(
model_name="template",
name="ca",
field=models.ForeignKey(
blank=True,
help_text="The Certificate Authority that will sign "
"certificates generated by this template.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="sample_pki.ca",
verbose_name="Certificate Authority",
),
),
migrations.AlterField(
model_name="template",
name="auto_cert",
field=models.BooleanField(
db_index=True,
default=openwisp_controller.config.base.template.default_auto_cert,
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 and certificate template types",
verbose_name="automatic tunnel provisioning",
),
),
migrations.AlterField(
model_name="template",
name="type",
field=models.CharField(
choices=[
("generic", "Generic"),
("vpn", "VPN-client"),
("cert", "Certificate"),
],
db_index=True,
default="generic",
help_text="template type, determines which features are available",
max_length=16,
verbose_name="type",
),
),
]
Loading