arthexis 0.1.10__py3-none-any.whl → 0.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

nodes/admin.py CHANGED
@@ -26,7 +26,10 @@ from .models import (
26
26
  NodeFeatureAssignment,
27
27
  ContentSample,
28
28
  NetMessage,
29
+ NodeManager,
30
+ DNSRecord,
29
31
  )
32
+ from . import dns as dns_utils
30
33
  from core.user_data import EntityModelAdmin
31
34
 
32
35
 
@@ -43,6 +46,129 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
43
46
  autocomplete_fields = ("feature",)
44
47
 
45
48
 
49
+ class DeployDNSRecordsForm(forms.Form):
50
+ manager = forms.ModelChoiceField(
51
+ label="Node Manager",
52
+ queryset=NodeManager.objects.none(),
53
+ help_text="Credentials used to authenticate with the DNS provider.",
54
+ )
55
+
56
+ def __init__(self, *args, **kwargs):
57
+ super().__init__(*args, **kwargs)
58
+ self.fields["manager"].queryset = NodeManager.objects.filter(
59
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
60
+ )
61
+
62
+
63
+ @admin.register(NodeManager)
64
+ class NodeManagerAdmin(EntityModelAdmin):
65
+ list_display = ("__str__", "provider", "is_enabled", "default_domain")
66
+ list_filter = ("provider", "is_enabled")
67
+ search_fields = (
68
+ "default_domain",
69
+ "user__username",
70
+ "group__name",
71
+ )
72
+
73
+
74
+ @admin.register(DNSRecord)
75
+ class DNSRecordAdmin(EntityModelAdmin):
76
+ list_display = (
77
+ "record_type",
78
+ "fqdn",
79
+ "data",
80
+ "ttl",
81
+ "node_manager",
82
+ "last_synced_at",
83
+ "last_verified_at",
84
+ )
85
+ list_filter = ("record_type", "provider", "node_manager")
86
+ search_fields = ("domain", "name", "data")
87
+ autocomplete_fields = ("node_manager",)
88
+ actions = ["deploy_selected_records", "validate_selected_records"]
89
+
90
+ def _default_manager_for_queryset(self, queryset):
91
+ manager_ids = list(
92
+ queryset.exclude(node_manager__isnull=True)
93
+ .values_list("node_manager_id", flat=True)
94
+ .distinct()
95
+ )
96
+ if len(manager_ids) == 1:
97
+ return manager_ids[0]
98
+ available = list(
99
+ NodeManager.objects.filter(
100
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
101
+ ).values_list("pk", flat=True)
102
+ )
103
+ if len(available) == 1:
104
+ return available[0]
105
+ return None
106
+
107
+ @admin.action(description="Deploy Selected records")
108
+ def deploy_selected_records(self, request, queryset):
109
+ unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
110
+ for record in unsupported:
111
+ self.message_user(
112
+ request,
113
+ f"{record} uses unsupported provider {record.get_provider_display()}",
114
+ messages.WARNING,
115
+ )
116
+ queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
117
+ if not queryset:
118
+ self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
119
+ return None
120
+
121
+ if "apply" in request.POST:
122
+ form = DeployDNSRecordsForm(request.POST)
123
+ if form.is_valid():
124
+ manager = form.cleaned_data["manager"]
125
+ result = manager.publish_dns_records(list(queryset))
126
+ for record, reason in result.skipped.items():
127
+ self.message_user(request, f"{record}: {reason}", messages.WARNING)
128
+ for record, reason in result.failures.items():
129
+ self.message_user(request, f"{record}: {reason}", messages.ERROR)
130
+ if result.deployed:
131
+ self.message_user(
132
+ request,
133
+ f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
134
+ messages.SUCCESS,
135
+ )
136
+ return None
137
+ else:
138
+ initial_manager = self._default_manager_for_queryset(queryset)
139
+ form = DeployDNSRecordsForm(initial={"manager": initial_manager})
140
+
141
+ context = {
142
+ **self.admin_site.each_context(request),
143
+ "opts": self.model._meta,
144
+ "form": form,
145
+ "queryset": queryset,
146
+ "title": "Deploy DNS records",
147
+ }
148
+ return render(
149
+ request,
150
+ "admin/nodes/dnsrecord/deploy_records.html",
151
+ context,
152
+ )
153
+
154
+ @admin.action(description="Validate Selected records")
155
+ def validate_selected_records(self, request, queryset):
156
+ resolver = dns_utils.create_resolver()
157
+ successes = 0
158
+ for record in queryset:
159
+ ok, message = dns_utils.validate_record(record, resolver=resolver)
160
+ if ok:
161
+ successes += 1
162
+ else:
163
+ self.message_user(request, f"{record}: {message}", messages.WARNING)
164
+ if successes:
165
+ self.message_user(
166
+ request,
167
+ f"Validated {successes} DNS record(s).",
168
+ messages.SUCCESS,
169
+ )
170
+
171
+
46
172
  @admin.register(Node)
47
173
  class NodeAdmin(EntityModelAdmin):
48
174
  list_display = (
@@ -232,12 +358,20 @@ class NodeAdmin(EntityModelAdmin):
232
358
 
233
359
  @admin.register(EmailOutbox)
234
360
  class EmailOutboxAdmin(EntityModelAdmin):
235
- list_display = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
361
+ list_display = (
362
+ "owner_label",
363
+ "host",
364
+ "port",
365
+ "username",
366
+ "use_tls",
367
+ "use_ssl",
368
+ "is_enabled",
369
+ )
236
370
  change_form_template = "admin/nodes/emailoutbox/change_form.html"
237
371
  fieldsets = (
238
372
  ("Owner", {"fields": ("user", "group", "node")}),
239
373
  (
240
- None,
374
+ "Configuration",
241
375
  {
242
376
  "fields": (
243
377
  "host",
@@ -247,6 +381,7 @@ class EmailOutboxAdmin(EntityModelAdmin):
247
381
  "use_tls",
248
382
  "use_ssl",
249
383
  "from_email",
384
+ "is_enabled",
250
385
  )
251
386
  },
252
387
  ),
nodes/backends.py CHANGED
@@ -38,11 +38,12 @@ class OutboxEmailBackend(BaseEmailBackend):
38
38
  user_id = self._resolve_identifier(message, "user")
39
39
  group_id = self._resolve_identifier(message, "group")
40
40
 
41
+ enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
41
42
  match_sets: list[tuple[str, list[EmailOutbox]]] = []
42
43
 
43
44
  if from_email:
44
45
  email_matches = list(
45
- EmailOutbox.objects.filter(
46
+ enabled_outboxes.filter(
46
47
  Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
47
48
  )
48
49
  )
@@ -50,22 +51,25 @@ class OutboxEmailBackend(BaseEmailBackend):
50
51
  match_sets.append(("from_email", email_matches))
51
52
 
52
53
  if node_id:
53
- node_matches = list(EmailOutbox.objects.filter(node_id=node_id))
54
+ node_matches = list(enabled_outboxes.filter(node_id=node_id))
54
55
  if node_matches:
55
56
  match_sets.append(("node", node_matches))
56
57
 
57
58
  if user_id:
58
- user_matches = list(EmailOutbox.objects.filter(user_id=user_id))
59
+ user_matches = list(enabled_outboxes.filter(user_id=user_id))
59
60
  if user_matches:
60
61
  match_sets.append(("user", user_matches))
61
62
 
62
63
  if group_id:
63
- group_matches = list(EmailOutbox.objects.filter(group_id=group_id))
64
+ group_matches = list(enabled_outboxes.filter(group_id=group_id))
64
65
  if group_matches:
65
66
  match_sets.append(("group", group_matches))
66
67
 
67
68
  if not match_sets:
68
- return EmailOutbox.objects.first(), []
69
+ fallback = self._fallback_outbox(enabled_outboxes)
70
+ if fallback:
71
+ return fallback, []
72
+ return None, []
69
73
 
70
74
  candidates: dict[int, EmailOutbox] = {}
71
75
  scores: defaultdict[int, int] = defaultdict(int)
@@ -76,7 +80,10 @@ class OutboxEmailBackend(BaseEmailBackend):
76
80
  scores[outbox.pk] += 1
77
81
 
78
82
  if not candidates:
79
- return EmailOutbox.objects.first(), []
83
+ fallback = self._fallback_outbox(enabled_outboxes)
84
+ if fallback:
85
+ return fallback, []
86
+ return None, []
80
87
 
81
88
  selected: EmailOutbox | None = None
82
89
  fallbacks: list[EmailOutbox] = []
@@ -93,6 +100,14 @@ class OutboxEmailBackend(BaseEmailBackend):
93
100
 
94
101
  return selected, fallbacks
95
102
 
103
+ def _fallback_outbox(self, queryset):
104
+ ownerless = queryset.filter(
105
+ node__isnull=True, user__isnull=True, group__isnull=True
106
+ ).order_by("pk").first()
107
+ if ownerless:
108
+ return ownerless
109
+ return queryset.order_by("pk").first()
110
+
96
111
  def send_messages(self, email_messages):
97
112
  sent = 0
98
113
  for message in email_messages:
nodes/dns.py ADDED
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
6
+
7
+ import requests
8
+ from django.utils import timezone
9
+ from dns import exception as dns_exception
10
+ from dns import resolver as dns_resolver
11
+ from requests import Response
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - imported for type checking only
14
+ from .models import DNSRecord, NodeManager
15
+
16
+
17
+ @dataclass
18
+ class DeploymentResult:
19
+ deployed: list["DNSRecord"]
20
+ failures: MutableMapping["DNSRecord", str]
21
+ skipped: MutableMapping["DNSRecord", str]
22
+
23
+
24
+ def _error_from_response(response: Response) -> str:
25
+ try:
26
+ payload = response.json()
27
+ except ValueError:
28
+ payload = None
29
+
30
+ if isinstance(payload, dict):
31
+ for key in ("message", "detail", "error"):
32
+ message = payload.get(key)
33
+ if message:
34
+ return str(message)
35
+ elif isinstance(payload, list) and payload:
36
+ first = payload[0]
37
+ if isinstance(first, Mapping) and "message" in first:
38
+ return str(first["message"])
39
+ return str(first)
40
+
41
+ reason = response.reason or ""
42
+ return f"{response.status_code} {reason}".strip()
43
+
44
+
45
+ def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
46
+ filtered: list["DNSRecord"] = []
47
+ skipped: MutableMapping["DNSRecord", str] = {}
48
+ for record in records:
49
+ if record.provider != record.Provider.GODADDY:
50
+ skipped[record] = "Unsupported DNS provider"
51
+ continue
52
+ domain = record.get_domain(manager)
53
+ if not domain:
54
+ skipped[record] = "Domain is required for deployment"
55
+ continue
56
+ filtered.append(record)
57
+
58
+ if not filtered:
59
+ return DeploymentResult([], {}, skipped)
60
+
61
+ session = requests.Session()
62
+ session.headers.update(
63
+ {
64
+ "Authorization": manager.get_auth_header(),
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ }
68
+ )
69
+ customer_id = manager.get_customer_id()
70
+ if customer_id:
71
+ session.headers["X-Shopper-Id"] = customer_id
72
+
73
+ grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
74
+ for record in filtered:
75
+ key = (
76
+ record.get_domain(manager),
77
+ record.record_type,
78
+ record.get_name(),
79
+ )
80
+ grouped[key].append(record)
81
+
82
+ deployed: list["DNSRecord"] = []
83
+ failures: MutableMapping["DNSRecord", str] = {}
84
+ now = timezone.now()
85
+
86
+ base_url = manager.get_base_url()
87
+ for (domain, record_type, name), grouped_records in grouped.items():
88
+ payload = [record.to_godaddy_payload() for record in grouped_records]
89
+ url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
90
+ try:
91
+ response = session.put(url, json=payload, timeout=30)
92
+ except requests.RequestException as exc:
93
+ message = str(exc)
94
+ for record in grouped_records:
95
+ record.mark_error(message, manager=manager)
96
+ failures[record] = message
97
+ continue
98
+
99
+ if response.status_code >= 400:
100
+ message = _error_from_response(response)
101
+ for record in grouped_records:
102
+ record.mark_error(message, manager=manager)
103
+ failures[record] = message
104
+ continue
105
+
106
+ for record in grouped_records:
107
+ record.mark_deployed(manager=manager, timestamp=now)
108
+ deployed.append(record)
109
+
110
+ return DeploymentResult(deployed, failures, skipped)
111
+
112
+
113
+ def create_resolver() -> dns_resolver.Resolver:
114
+ return dns_resolver.Resolver()
115
+
116
+
117
+ def _normalize_hostname(value: str) -> str:
118
+ return value.rstrip(".").lower()
119
+
120
+
121
+ def _extract_txt(rdata) -> str:
122
+ strings = getattr(rdata, "strings", None)
123
+ if strings:
124
+ parts = []
125
+ for segment in strings:
126
+ if isinstance(segment, bytes):
127
+ parts.append(segment.decode("utf-8", "ignore"))
128
+ else:
129
+ parts.append(str(segment))
130
+ return "".join(parts)
131
+ return str(rdata).strip('"')
132
+
133
+
134
+ def _matches_record(record: "DNSRecord", rdata) -> bool:
135
+ expected = (record.resolve_sigils("data") or "").strip()
136
+ rtype = record.record_type
137
+
138
+ if rtype == record.Type.A:
139
+ return getattr(rdata, "address", str(rdata)) == expected
140
+ if rtype == record.Type.AAAA:
141
+ return getattr(rdata, "address", str(rdata)) == expected
142
+ if rtype == record.Type.CNAME:
143
+ actual = getattr(rdata, "target", None)
144
+ if actual is None:
145
+ return False
146
+ return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
147
+ if rtype == record.Type.NS:
148
+ actual = getattr(rdata, "target", None)
149
+ if actual is None:
150
+ return False
151
+ return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
152
+ if rtype == record.Type.MX:
153
+ host = getattr(rdata, "exchange", None)
154
+ if host is None:
155
+ return False
156
+ host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
157
+ priority = getattr(rdata, "preference", None)
158
+ priority_match = record.priority is None or priority == record.priority
159
+ return host_match and priority_match
160
+ if rtype == record.Type.SRV:
161
+ target = getattr(rdata, "target", None)
162
+ if target is None:
163
+ return False
164
+ target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
165
+ priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
166
+ weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
167
+ port_match = record.port is None or getattr(rdata, "port", None) == record.port
168
+ return target_match and priority_match and weight_match and port_match
169
+ if rtype == record.Type.TXT:
170
+ actual = _extract_txt(rdata)
171
+ return actual == expected
172
+
173
+ return str(rdata).strip() == expected
174
+
175
+
176
+ def validate_record(
177
+ record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
178
+ ) -> tuple[bool, str]:
179
+ resolver = resolver or create_resolver()
180
+ fqdn = record.fqdn()
181
+ if not fqdn:
182
+ message = "Domain is required for validation"
183
+ record.mark_error(message)
184
+ return False, message
185
+
186
+ try:
187
+ answers = resolver.resolve(fqdn, record.record_type)
188
+ except (dns_exception.DNSException, OSError) as exc:
189
+ message = str(exc)
190
+ record.mark_error(message)
191
+ return False, message
192
+
193
+ for rdata in answers:
194
+ if _matches_record(record, rdata):
195
+ record.last_verified_at = timezone.now()
196
+ record.last_error = ""
197
+ record.save(update_fields=["last_verified_at", "last_error"])
198
+ return True, ""
199
+
200
+ message = "DNS record does not match expected value"
201
+ record.mark_error(message)
202
+ return False, message
203
+