Coverage for src/meshadmin/server/networks/forms.py: 87%

203 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-10 16:08 +0200

1import ipaddress 

2from datetime import timedelta 

3 

4import structlog 

5from django import forms 

6from django.contrib.auth import get_user_model 

7from django.core.exceptions import ValidationError 

8from django.shortcuts import get_object_or_404 

9from django.utils import timezone 

10 

11from meshadmin.server.networks.models import ( 

12 CA, 

13 Group, 

14 Host, 

15 Network, 

16 NetworkMembership, 

17 Rule, 

18 Template, 

19) 

20from meshadmin.server.networks.services import ( 

21 create_network, 

22 create_network_ca, 

23 network_available_hosts_iterator, 

24) 

25 

26logger = structlog.get_logger(__name__) 

27 

28User = get_user_model() 

29 

30RECOMMENDED_RANGES = [ 

31 ("192.168.0.0/16", "Private network range, ideal for small networks"), 

32 ("172.16.0.0/12", "Private network range, good for medium networks"), 

33 ("10.0.0.0/8", "Private network range, suitable for large networks"), 

34 ("100.64.0.0/10", "Carrier-grade NAT range, recommended by Nebula docs"), 

35] 

36 

37 

38class NetworkForm(forms.ModelForm): 

39 class Meta: 

40 model = Network 

41 fields = ("name", "cidr", "update_interval") 

42 widgets = { 

43 "update_interval": forms.NumberInput(attrs={"min": 5, "max": 3600}), 

44 } 

45 

46 def __init__(self, *args, **kwargs): 

47 self.request = kwargs.pop("request", None) 

48 super().__init__(*args, **kwargs) 

49 

50 def clean_cidr(self): 

51 cidr = self.cleaned_data["cidr"] 

52 

53 try: 

54 network = ipaddress.ip_network(cidr) 

55 except ValueError as e: 

56 raise ValidationError(f"Invalid CIDR format: {str(e)}") 

57 

58 in_recommended_range = False 

59 for recommended_cidr, _ in RECOMMENDED_RANGES: 

60 if ipaddress.ip_network(cidr).subnet_of( 

61 ipaddress.ip_network(recommended_cidr) 

62 ): 

63 in_recommended_range = True 

64 break 

65 

66 if not in_recommended_range: 

67 suggestions = [] 

68 network_size = network.num_addresses 

69 for recommended_cidr, description in RECOMMENDED_RANGES: 

70 rec_network = ipaddress.ip_network(recommended_cidr) 

71 if network_size <= rec_network.num_addresses: 

72 suggestions.append(f"{recommended_cidr} - {description}") 

73 

74 raise ValidationError( 

75 f"Warning: The CIDR {cidr} is outside recommended network ranges. " 

76 "Consider using one of these ranges instead:\n" 

77 + "\n".join(f"{suggestion}" for suggestion in suggestions) 

78 ) 

79 

80 return cidr 

81 

82 def save(self, commit=True): 

83 logger.info("save network") 

84 instance = super().save(commit=False) 

85 if not instance.pk: 

86 instance = create_network( 

87 network_name=self.cleaned_data["name"], 

88 network_cidr=self.cleaned_data["cidr"], 

89 update_interval=self.cleaned_data["update_interval"], 

90 user=self.request.user, 

91 ) 

92 elif commit: 

93 instance.save() 

94 

95 self.save_m2m() 

96 return instance 

97 

98 

99class CAForm(forms.ModelForm): 

100 class Meta: 

101 model = CA 

102 fields = ("name", "network") 

103 

104 def __init__(self, *args, **kwargs): 

105 network = kwargs.pop("network", None) 

106 super().__init__(*args, **kwargs) 

107 

108 if network: 

109 self.fields.pop("network") 

110 self.instance.network = network 

111 

112 def save(self, commit=True): 

113 instance = super().save(commit=False) 

114 

115 if instance.pk: 

116 if commit: 

117 instance.save() 

118 else: 

119 instance = create_network_ca(instance.name, instance.network) 

120 

121 return instance 

122 

123 

124class GroupForm(forms.ModelForm): 

125 class Meta: 

126 model = Group 

127 fields = ("name", "description") 

128 

129 def __init__(self, *args, **kwargs): 

130 network = kwargs.pop("network", None) 

131 super().__init__(*args, **kwargs) 

132 

133 if network: 

134 self.instance.network = network 

135 

136 

137class TemplateForm(forms.ModelForm): 

138 expiry_days = forms.IntegerField( 

139 required=False, 

140 min_value=1, 

141 label="Expires in (days)", 

142 help_text="Days until the key expires. Leave empty for no expiration.", 

143 ) 

144 

145 class Meta: 

146 model = Template 

147 fields = ( 

148 "name", 

149 "network", 

150 "is_lighthouse", 

151 "is_relay", 

152 "use_relay", 

153 "groups", 

154 "reusable", 

155 "usage_limit", 

156 "ephemeral_peers", 

157 ) 

158 

159 def __init__(self, *args, **kwargs): 

160 network = kwargs.pop("network", None) 

161 super().__init__(*args, **kwargs) 

162 

163 if self.instance and self.instance.pk and self.instance.expires_at: 

164 days_remaining = (self.instance.expires_at - timezone.now()).days 

165 if days_remaining > 0: 

166 self.fields["expiry_days"].initial = days_remaining 

167 

168 network_id = None 

169 if network: 

170 network_id = network.id 

171 self.fields.pop("network") 

172 self.instance.network = network 

173 elif self.instance and self.instance.pk: 

174 network_id = self.instance.network_id 

175 elif "initial" in kwargs and "network_id" in kwargs["initial"]: 

176 network_id = kwargs["initial"]["network_id"] 

177 

178 if network_id: 

179 self.fields["groups"].queryset = Group.objects.filter( 

180 network_id=network_id 

181 ).all() 

182 

183 def save(self, commit=True): 

184 instance = super().save(commit=False) 

185 

186 expiry_days = self.cleaned_data.get("expiry_days") 

187 if expiry_days: 

188 instance.expires_at = timezone.now() + timedelta(days=expiry_days) 

189 elif "expiry_days" in self.changed_data: 

190 instance.expires_at = None 

191 

192 if commit: 

193 instance.save() 

194 self.save_m2m() 

195 

196 return instance 

197 

198 

199class HostForm(forms.ModelForm): 

200 class Meta: 

201 model = Host 

202 fields = ( 

203 "name", 

204 "network", 

205 "assigned_ip", 

206 "is_lighthouse", 

207 "is_relay", 

208 "use_relay", 

209 "groups", 

210 "public_ip_or_hostname", 

211 "public_auth_key", 

212 "interface", 

213 ) 

214 

215 def __init__(self, *args, **kwargs): 

216 network = kwargs.pop("network", None) 

217 super().__init__(*args, **kwargs) 

218 

219 network_id = None 

220 if network: 

221 network_id = network.id 

222 self.fields.pop("network") 

223 self.instance.network = network 

224 elif self.instance and self.instance.pk: 

225 network_id = self.instance.network_id 

226 

227 if network_id: 

228 self.fields["groups"].queryset = Group.objects.filter( 

229 network_id=network_id 

230 ).all() 

231 

232 if not self.instance.pk: 

233 network = Network.objects.get(id=network_id) 

234 ipv4_iterator = network_available_hosts_iterator(network) 

235 self.initial["assigned_ip"] = next(ipv4_iterator) 

236 

237 

238class RuleForm(forms.ModelForm): 

239 class Meta: 

240 model = Rule 

241 fields = ( 

242 "direction", 

243 "proto", 

244 "port", 

245 "group", 

246 "groups", 

247 "cidr", 

248 "local_cidr", 

249 ) 

250 

251 def __init__(self, *args, **kwargs): 

252 super().__init__(*args, **kwargs) 

253 security_group_id = kwargs.get("initial", {}).get("security_group_id") 

254 if not security_group_id and kwargs.get("instance"): 

255 security_group_id = kwargs["instance"].security_group_id 

256 

257 if security_group_id: 

258 security_group = get_object_or_404(Group, id=security_group_id) 

259 group_queryset = Group.objects.filter(network_id=security_group.network_id) 

260 self.fields["group"].queryset = group_queryset 

261 self.fields["groups"].queryset = group_queryset 

262 

263 def clean(self): 

264 cleaned_data = super().clean() 

265 

266 # Validate port format 

267 port = cleaned_data.get("port") 

268 if port and port not in ("0", "any", "fragment"): 

269 if "-" in port: 

270 try: 

271 start, end = map(int, port.split("-")) 

272 if not (0 <= start <= 65535 and 0 <= end <= 65535): 

273 raise ValueError 

274 except ValueError: 

275 raise ValidationError( 

276 { 

277 "port": "Port range must be two valid port numbers (0-65535) separated by a hyphen" 

278 } 

279 ) 

280 elif not port.isdigit() or not 0 <= int(port) <= 65535: 

281 raise ValidationError( 

282 { 

283 "port": "Port must be 'any', 'fragment', or a number between 0 and 65535" 

284 } 

285 ) 

286 

287 # Validate that at least one target specification exists 

288 group = cleaned_data.get("group") 

289 groups = cleaned_data.get("groups") 

290 cidr = cleaned_data.get("cidr") 

291 

292 if not any([group, groups.exists() if groups else False, cidr]): 

293 raise ValidationError( 

294 "At least one of group, groups, or CIDR must be specified to identify " 

295 "which hosts the rule applies to." 

296 ) 

297 

298 # Validate CIDR format if provided 

299 if cidr: 

300 try: 

301 ipaddress.ip_network(cidr) 

302 except ValueError: 

303 raise ValidationError( 

304 {"cidr": "Invalid CIDR format. Example: 0.0.0.0/0"} 

305 ) 

306 

307 # Validate local_cidr format if provided 

308 local_cidr = cleaned_data.get("local_cidr") 

309 if local_cidr: 

310 try: 

311 ipaddress.ip_network(local_cidr) 

312 except ValueError: 

313 raise ValidationError( 

314 {"local_cidr": "Invalid CIDR format. Example: 0.0.0.0/0"} 

315 ) 

316 

317 return cleaned_data 

318 

319 

320class NetworkMembershipForm(forms.ModelForm): 

321 email = forms.EmailField( 

322 help_text="Enter the email address of the user you want to add to the network" 

323 ) 

324 

325 class Meta: 

326 model = NetworkMembership 

327 fields = ["role"] 

328 

329 def __init__(self, *args, **kwargs): 

330 self.network = kwargs.pop("network", None) 

331 super().__init__(*args, **kwargs) 

332 if self.instance.pk: 

333 self.fields["email"].initial = self.instance.user.email 

334 self.fields["email"].disabled = True 

335 

336 def clean_email(self): 

337 email = self.cleaned_data["email"] 

338 user = User.objects.filter(email=email).first() 

339 if not user: 

340 raise ValidationError("No user found with this email address") 

341 if ( 

342 self.network 

343 and NetworkMembership.objects.filter( 

344 network=self.network, user=user 

345 ).exists() 

346 ): 

347 raise ValidationError("This user is already a member of the network") 

348 return email 

349 

350 def save(self, commit=True): 

351 instance = super().save(commit=False) 

352 if not instance.pk: 

353 email = self.cleaned_data["email"] 

354 user = User.objects.get(email=email) 

355 instance.user = user 

356 instance.network = self.network 

357 if commit: 

358 instance.save() 

359 return instance