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

1from datetime import datetime, timedelta 

2from uuid import uuid4 

3 

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 

9 

10User = get_user_model() 

11 

12logger = structlog.get_logger(__name__) 

13 

14 

15class TimestampedModel(models.Model): 

16 created_at = models.DateTimeField(auto_now_add=True) 

17 updated_at = models.DateTimeField(auto_now=True) 

18 

19 class Meta: 

20 abstract = True 

21 

22 

23class NetworkMembership(TimestampedModel): 

24 class Role(models.TextChoices): 

25 ADMIN = "ADMIN", "Admin" 

26 MEMBER = "MEMBER", "Member" 

27 

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) 

33 

34 class Meta: 

35 constraints = [ 

36 UniqueConstraint( 

37 fields=("network", "user"), name="unique_network_membership" 

38 ), 

39 ] 

40 

41 def __str__(self): 

42 return f"{self.user} - {self.network} {self.role}" 

43 

44 

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 ) 

54 

55 def __str__(self): 

56 return self.name 

57 

58 

59class CA(TimestampedModel): 

60 network = models.ForeignKey(Network, on_delete=models.CASCADE) 

61 name = models.CharField(max_length=200) 

62 

63 key = models.TextField() 

64 cert = models.TextField() 

65 cert_print = models.JSONField(blank=True, null=True) 

66 

67 def __str__(self): 

68 return self.name 

69 

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 

78 

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 

87 

88 

89class SigningCA(TimestampedModel): 

90 network = models.OneToOneField(Network, on_delete=models.CASCADE) 

91 ca = models.ForeignKey(CA, on_delete=models.CASCADE) 

92 

93 def __str__(self): 

94 return self.ca.name 

95 

96 

97class Group(TimestampedModel): 

98 class Meta: 

99 constraints = [ 

100 UniqueConstraint( 

101 fields=("network", "name"), name="unique_group_name_per_network" 

102 ), 

103 ] 

104 

105 network = models.ForeignKey(Network, on_delete=models.CASCADE) 

106 name = models.CharField(max_length=200) 

107 description = models.TextField(blank=True) 

108 

109 def __str__(self): 

110 return self.name 

111 

112 

113class Rule(TimestampedModel): 

114 class Direction(models.TextChoices): 

115 INBOUND = "I", "inbound" 

116 OUTBOUND = "O", "outbound" 

117 

118 security_group = models.ForeignKey( 

119 Group, on_delete=models.CASCADE, related_name="rules" 

120 ) 

121 

122 direction = models.CharField( 

123 max_length=10, choices=Direction.choices, default=Direction.INBOUND 

124 ) 

125 

126 class Protocol(models.TextChoices): 

127 ANY = "any", "any" 

128 UDP = "udp", "udp" 

129 TCP = "tcp", "tcp" 

130 ICMP = "icmp", "icmp" 

131 

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 ) 

138 

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 ) 

148 

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 ) 

157 

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 ) 

167 

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 ) 

174 

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 ) 

185 

186 

187class Host(TimestampedModel): 

188 class Meta: 

189 unique_together = (("network", "name"), ("network", "assigned_ip")) 

190 

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) 

194 

195 is_lighthouse = models.BooleanField(default=False) 

196 public_ip_or_hostname = models.CharField(max_length=200, blank=True, null=True) 

197 

198 is_relay = models.BooleanField(default=False) 

199 use_relay = models.BooleanField(default=True) 

200 

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") 

206 

207 last_config_refresh = models.DateTimeField(blank=True, null=True) 

208 

209 config_freeze = models.BooleanField( 

210 default=False, 

211 help_text="When true, host will not receive automatic config updates", 

212 ) 

213 

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 ) 

218 

219 def __str__(self): 

220 return self.name 

221 

222 @property 

223 def is_config_stale(self): 

224 if not self.last_config_refresh: 

225 return True 

226 

227 stale_threshold = timezone.now() - timedelta(hours=24) 

228 return self.last_config_refresh < stale_threshold 

229 

230 

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) 

236 

237 

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) 

242 

243 

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) 

252 

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 ) 

273 

274 def __str__(self): 

275 return self.name 

276 

277 

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)