Coverage for src/meshadmin/server/networks/models.py: 94%
139 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
1from datetime import datetime, timedelta
2from uuid import uuid4
4import structlog
5from django.contrib.auth import get_user_model
6from django.db import models
7from django.db.models import UniqueConstraint
8from django.utils import timezone
10User = get_user_model()
12logger = structlog.get_logger(__name__)
15class TimestampedModel(models.Model):
16 created_at = models.DateTimeField(auto_now_add=True)
17 updated_at = models.DateTimeField(auto_now=True)
19 class Meta:
20 abstract = True
23class NetworkMembership(TimestampedModel):
24 class Role(models.TextChoices):
25 ADMIN = "ADMIN", "Admin"
26 MEMBER = "MEMBER", "Member"
28 network = models.ForeignKey(
29 "Network", on_delete=models.CASCADE, related_name="memberships"
30 )
31 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="memberships")
32 role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
34 class Meta:
35 constraints = [
36 UniqueConstraint(
37 fields=("network", "user"), name="unique_network_membership"
38 ),
39 ]
41 def __str__(self):
42 return f"{self.user} - {self.network} {self.role}"
45class Network(TimestampedModel):
46 name = models.CharField(max_length=200, unique=True)
47 cidr = models.CharField(max_length=200, default="100.100.64.0/24")
48 update_interval = models.IntegerField(
49 default=5, help_text="Interval in seconds for host configuration updates"
50 )
51 members = models.ManyToManyField(
52 User, through="NetworkMembership", related_name="networks"
53 )
55 def __str__(self):
56 return self.name
59class CA(TimestampedModel):
60 network = models.ForeignKey(Network, on_delete=models.CASCADE)
61 name = models.CharField(max_length=200)
63 key = models.TextField()
64 cert = models.TextField()
65 cert_print = models.JSONField(blank=True, null=True)
67 def __str__(self):
68 return self.name
70 @property
71 def days_until_expiry(self):
72 if (
73 not self.cert_print
74 or "details" not in self.cert_print
75 or "notAfter" not in self.cert_print["details"]
76 ):
77 return None
79 expiry_str = self.cert_print["details"]["notAfter"]
80 try:
81 expiry_date = datetime.fromisoformat(expiry_str)
82 days = (expiry_date - timezone.now()).days
83 return max(0, days)
84 except (ValueError, TypeError) as e:
85 logger.error("error parsing expiry date", ca_name=self.name, error=e)
86 return None
89class SigningCA(TimestampedModel):
90 network = models.OneToOneField(Network, on_delete=models.CASCADE)
91 ca = models.ForeignKey(CA, on_delete=models.CASCADE)
93 def __str__(self):
94 return self.ca.name
97class Group(TimestampedModel):
98 class Meta:
99 constraints = [
100 UniqueConstraint(
101 fields=("network", "name"), name="unique_group_name_per_network"
102 ),
103 ]
105 network = models.ForeignKey(Network, on_delete=models.CASCADE)
106 name = models.CharField(max_length=200)
107 description = models.TextField(blank=True)
109 def __str__(self):
110 return self.name
113class Rule(TimestampedModel):
114 class Direction(models.TextChoices):
115 INBOUND = "I", "inbound"
116 OUTBOUND = "O", "outbound"
118 security_group = models.ForeignKey(
119 Group, on_delete=models.CASCADE, related_name="rules"
120 )
122 direction = models.CharField(
123 max_length=10, choices=Direction.choices, default=Direction.INBOUND
124 )
126 class Protocol(models.TextChoices):
127 ANY = "any", "any"
128 UDP = "udp", "udp"
129 TCP = "tcp", "tcp"
130 ICMP = "icmp", "icmp"
132 proto = models.CharField(
133 max_length=4,
134 choices=Protocol.choices,
135 default=Protocol.ANY,
136 help_text="One of any, tcp, udp, or icmp",
137 )
139 port = models.CharField(
140 max_length=255,
141 help_text=(
142 "Takes 0 or any as any, a single number (e.g. 80), a range (e.g. 200-901), "
143 "or fragment to match second and further fragments of fragmented packets "
144 "(since there is no port available)."
145 ),
146 default="any",
147 )
149 group = models.ForeignKey(
150 Group,
151 on_delete=models.CASCADE,
152 help_text="Can be any or a literal group name, ie default-group",
153 blank=True,
154 null=True,
155 related_name="fw_groups",
156 )
158 groups = models.ManyToManyField(
159 Group,
160 blank=True,
161 related_name="fw_groupss",
162 help_text=(
163 "Same as group but accepts multiple values. Multiple values are AND'd together "
164 "and a certificate must contain all groups to pass."
165 ),
166 )
168 cidr = models.CharField(
169 max_length=255,
170 help_text="a CIDR, 0.0.0.0/0 is any. This restricts which Nebula IP addresses the rule allows.",
171 blank=True,
172 null=True,
173 )
175 local_cidr = models.CharField(
176 max_length=255,
177 help_text=(
178 "a local CIDR, 0.0.0.0/0 is any. This restricts which destination IP addresses, "
179 "when using unsafe_routes, the rule allows. If unset, the rule will allow access "
180 "to the specified ports on both the node itself as well as any IP addresses it routes to."
181 ),
182 blank=True,
183 null=True,
184 )
187class Host(TimestampedModel):
188 class Meta:
189 unique_together = (("network", "name"), ("network", "assigned_ip"))
191 network = models.ForeignKey(Network, on_delete=models.CASCADE)
192 name = models.CharField(max_length=200)
193 assigned_ip = models.CharField(max_length=200, blank=True, null=True)
195 is_lighthouse = models.BooleanField(default=False)
196 public_ip_or_hostname = models.CharField(max_length=200, blank=True, null=True)
198 is_relay = models.BooleanField(default=False)
199 use_relay = models.BooleanField(default=True)
201 public_key = models.TextField(max_length=1000, blank=True, null=True)
202 public_auth_kid = models.CharField(max_length=200, blank=True, null=True)
203 public_auth_key = models.TextField(max_length=1000, blank=True, null=True)
204 groups = models.ManyToManyField(Group, blank=True)
205 interface = models.CharField(max_length=200, default="nebula1")
207 last_config_refresh = models.DateTimeField(blank=True, null=True)
209 config_freeze = models.BooleanField(
210 default=False,
211 help_text="When true, host will not receive automatic config updates",
212 )
214 is_ephemeral = models.BooleanField(
215 default=False,
216 help_text="When true, this host will be removed if offline for over 10 minutes",
217 )
219 def __str__(self):
220 return self.name
222 @property
223 def is_config_stale(self):
224 if not self.last_config_refresh:
225 return True
227 stale_threshold = timezone.now() - timedelta(hours=24)
228 return self.last_config_refresh < stale_threshold
231class HostCert(TimestampedModel):
232 host = models.ForeignKey(Host, on_delete=models.CASCADE)
233 ca = models.ForeignKey(CA, on_delete=models.CASCADE)
234 cert = models.TextField(max_length=1000)
235 hash = models.IntegerField(default=0)
238class HostConfig(TimestampedModel):
239 host = models.ForeignKey(Host, on_delete=models.CASCADE)
240 config = models.TextField()
241 sha256 = models.CharField(max_length=200, blank=True, null=True)
244class Template(TimestampedModel):
245 name = models.CharField(max_length=200)
246 network = models.ForeignKey(Network, on_delete=models.CASCADE)
247 is_lighthouse = models.BooleanField(default=False)
248 is_relay = models.BooleanField(default=False)
249 use_relay = models.BooleanField(default=True)
250 groups = models.ManyToManyField(Group, blank=True)
251 enrollment_key = models.CharField(max_length=255, default=uuid4, unique=True)
253 reusable = models.BooleanField(
254 default=True, help_text="When false, this key can not be used multiple times"
255 )
256 usage_limit = models.IntegerField(
257 null=True,
258 blank=True,
259 help_text="Maximum number of peers that can enroll with this key. Null means unlimited.",
260 )
261 expires_at = models.DateTimeField(
262 null=True,
263 blank=True,
264 help_text="When this key expires. Null means no expiration.",
265 )
266 usage_count = models.IntegerField(
267 default=0, help_text="Number of times this key has been used"
268 )
269 ephemeral_peers = models.BooleanField(
270 default=False,
271 help_text="When true, peers that are offline for over 10 minutes will be removed",
272 )
274 def __str__(self):
275 return self.name
278class ConfigRollout(TimestampedModel):
279 name = models.CharField(max_length=200)
280 status = models.CharField(
281 max_length=20,
282 choices=[
283 ("PENDING", "Pending"),
284 ("IN_PROGRESS", "In Progress"),
285 ("COMPLETED", "Completed"),
286 ("FAILED", "Failed"),
287 ],
288 default="PENDING",
289 )
290 network = models.ForeignKey(Network, on_delete=models.CASCADE)
291 target_hosts = models.ManyToManyField(Host, related_name="pending_rollouts")
292 completed_hosts = models.ManyToManyField(Host, related_name="completed_rollouts")
293 notes = models.TextField(blank=True)