Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
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")),
)


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."
),
)
blueprint_cert = models.ForeignKey(
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."
)
}
)
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",
),
),
]
145 changes: 145 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,148 @@ 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")

def test_organization_validation_skipped_for_non_cert(self):
"""
Organization validation for 'ca' and 'blueprint_cert'
should not run if the template is not type 'cert'.
"""
org1 = self._get_org()
org2 = self._create_org(name="Org2", slug="org2")
ca_org2 = self._create_ca(organization=org2)
blueprint_org2 = self._create_cert(ca=ca_org2, organization=org2)
t = self._create_template(
name="stale-relations",
type="generic",
backend="netjsonconfig.OpenWrt",
ca=ca_org2,
blueprint_cert=blueprint_org2,
organization=org1,
config={"interfaces": [{"name": "eth0", "type": "ethernet"}]},
)
try:
t.full_clean()
except ValidationError as err:
if "organization" in err.message_dict:
self.fail(
"Organization validation ran on stale cert relations for a non-cert template."
)
raise err
self.assertIsNone(t.ca)
self.assertIsNone(t.blueprint_cert)
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
Loading
Loading