arthexis 0.1.9__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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from collections.abc import Iterable
|
|
2
4
|
from django.db import models
|
|
5
|
+
from django.db.utils import DatabaseError
|
|
3
6
|
from django.db.models.signals import post_delete
|
|
4
|
-
from django.dispatch import receiver
|
|
7
|
+
from django.dispatch import Signal, receiver
|
|
5
8
|
from core.entity import Entity
|
|
6
9
|
from core.models import Profile
|
|
7
|
-
from core.fields import SigilShortAutoField
|
|
10
|
+
from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
8
11
|
import re
|
|
9
12
|
import json
|
|
10
13
|
import base64
|
|
14
|
+
from django.utils import timezone
|
|
11
15
|
from django.utils.text import slugify
|
|
12
16
|
from django.conf import settings
|
|
13
17
|
from django.contrib.sites.models import Site
|
|
18
|
+
from datetime import timedelta
|
|
14
19
|
import uuid
|
|
15
20
|
import os
|
|
16
21
|
import shutil
|
|
@@ -19,6 +24,7 @@ import stat
|
|
|
19
24
|
import subprocess
|
|
20
25
|
from pathlib import Path
|
|
21
26
|
from utils import revision
|
|
27
|
+
from core.notifications import notify_async
|
|
22
28
|
from django.core.exceptions import ValidationError
|
|
23
29
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
24
30
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
@@ -119,12 +125,23 @@ def get_terminal_role():
|
|
|
119
125
|
class Node(Entity):
|
|
120
126
|
"""Information about a running node in the network."""
|
|
121
127
|
|
|
128
|
+
class Relation(models.TextChoices):
|
|
129
|
+
UPSTREAM = "UPSTREAM", "Upstream"
|
|
130
|
+
DOWNSTREAM = "DOWNSTREAM", "Downstream"
|
|
131
|
+
PEER = "PEER", "Peer"
|
|
132
|
+
SELF = "SELF", "Self"
|
|
133
|
+
|
|
122
134
|
hostname = models.CharField(max_length=100)
|
|
123
135
|
address = models.GenericIPAddressField()
|
|
124
136
|
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
125
137
|
port = models.PositiveIntegerField(default=8000)
|
|
126
138
|
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
127
139
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
140
|
+
current_relation = models.CharField(
|
|
141
|
+
max_length=10,
|
|
142
|
+
choices=Relation.choices,
|
|
143
|
+
default=Relation.PEER,
|
|
144
|
+
)
|
|
128
145
|
last_seen = models.DateTimeField(auto_now=True)
|
|
129
146
|
enable_public_api = models.BooleanField(
|
|
130
147
|
default=False,
|
|
@@ -175,11 +192,35 @@ class Node(Entity):
|
|
|
175
192
|
"""Return the MAC address of the current host."""
|
|
176
193
|
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
177
194
|
|
|
195
|
+
@classmethod
|
|
196
|
+
def normalize_relation(cls, value):
|
|
197
|
+
"""Normalize ``value`` to a valid :class:`Relation`."""
|
|
198
|
+
|
|
199
|
+
if isinstance(value, cls.Relation):
|
|
200
|
+
return value
|
|
201
|
+
if value is None:
|
|
202
|
+
return cls.Relation.PEER
|
|
203
|
+
text = str(value).strip()
|
|
204
|
+
if not text:
|
|
205
|
+
return cls.Relation.PEER
|
|
206
|
+
for relation in cls.Relation:
|
|
207
|
+
if text.lower() == relation.label.lower():
|
|
208
|
+
return relation
|
|
209
|
+
if text.upper() == relation.name:
|
|
210
|
+
return relation
|
|
211
|
+
if text.lower() == relation.value.lower():
|
|
212
|
+
return relation
|
|
213
|
+
return cls.Relation.PEER
|
|
214
|
+
|
|
178
215
|
@classmethod
|
|
179
216
|
def get_local(cls):
|
|
180
217
|
"""Return the node representing the current host if it exists."""
|
|
181
218
|
mac = cls.get_current_mac()
|
|
182
|
-
|
|
219
|
+
try:
|
|
220
|
+
return cls.objects.filter(mac_address=mac).first()
|
|
221
|
+
except DatabaseError:
|
|
222
|
+
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
223
|
+
return None
|
|
183
224
|
|
|
184
225
|
@classmethod
|
|
185
226
|
def register_current(cls):
|
|
@@ -209,6 +250,7 @@ class Node(Entity):
|
|
|
209
250
|
"installed_revision": installed_revision,
|
|
210
251
|
"public_endpoint": slug,
|
|
211
252
|
"mac_address": mac,
|
|
253
|
+
"current_relation": cls.Relation.SELF,
|
|
212
254
|
}
|
|
213
255
|
if node:
|
|
214
256
|
for field, value in defaults.items():
|
|
@@ -235,8 +277,112 @@ class Node(Entity):
|
|
|
235
277
|
node.save(update_fields=["role"])
|
|
236
278
|
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
237
279
|
node.ensure_keys()
|
|
280
|
+
node.notify_peers_of_update()
|
|
238
281
|
return node, created
|
|
239
282
|
|
|
283
|
+
def notify_peers_of_update(self):
|
|
284
|
+
"""Attempt to update this node's registration with known peers."""
|
|
285
|
+
|
|
286
|
+
from secrets import token_hex
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
import requests
|
|
290
|
+
except Exception: # pragma: no cover - requests should be available
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
|
|
294
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
295
|
+
if not priv_path.exists():
|
|
296
|
+
logger.debug("Private key for %s not found; skipping peer update", self)
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
private_key = serialization.load_pem_private_key(
|
|
300
|
+
priv_path.read_bytes(), password=None
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
303
|
+
logger.warning("Failed to load private key for %s: %s", self, exc)
|
|
304
|
+
return
|
|
305
|
+
token = token_hex(16)
|
|
306
|
+
try:
|
|
307
|
+
signature = private_key.sign(
|
|
308
|
+
token.encode(),
|
|
309
|
+
padding.PKCS1v15(),
|
|
310
|
+
hashes.SHA256(),
|
|
311
|
+
)
|
|
312
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
313
|
+
logger.warning("Failed to sign peer update for %s: %s", self, exc)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
payload = {
|
|
317
|
+
"hostname": self.hostname,
|
|
318
|
+
"address": self.address,
|
|
319
|
+
"port": self.port,
|
|
320
|
+
"mac_address": self.mac_address,
|
|
321
|
+
"public_key": self.public_key,
|
|
322
|
+
"token": token,
|
|
323
|
+
"signature": base64.b64encode(signature).decode(),
|
|
324
|
+
}
|
|
325
|
+
if self.installed_version:
|
|
326
|
+
payload["installed_version"] = self.installed_version
|
|
327
|
+
if self.installed_revision:
|
|
328
|
+
payload["installed_revision"] = self.installed_revision
|
|
329
|
+
|
|
330
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
331
|
+
headers = {"Content-Type": "application/json"}
|
|
332
|
+
|
|
333
|
+
peers = Node.objects.exclude(pk=self.pk)
|
|
334
|
+
for peer in peers:
|
|
335
|
+
host_candidates: list[str] = []
|
|
336
|
+
if peer.address:
|
|
337
|
+
host_candidates.append(peer.address)
|
|
338
|
+
if peer.hostname and peer.hostname not in host_candidates:
|
|
339
|
+
host_candidates.append(peer.hostname)
|
|
340
|
+
port = peer.port or 8000
|
|
341
|
+
urls: list[str] = []
|
|
342
|
+
for host in host_candidates:
|
|
343
|
+
host = host.strip()
|
|
344
|
+
if not host:
|
|
345
|
+
continue
|
|
346
|
+
if ":" in host and not host.startswith("["):
|
|
347
|
+
host = f"[{host}]"
|
|
348
|
+
http_url = (
|
|
349
|
+
f"http://{host}/nodes/register/"
|
|
350
|
+
if port == 80
|
|
351
|
+
else f"http://{host}:{port}/nodes/register/"
|
|
352
|
+
)
|
|
353
|
+
https_url = (
|
|
354
|
+
f"https://{host}/nodes/register/"
|
|
355
|
+
if port in {80, 443}
|
|
356
|
+
else f"https://{host}:{port}/nodes/register/"
|
|
357
|
+
)
|
|
358
|
+
for url in (https_url, http_url):
|
|
359
|
+
if url not in urls:
|
|
360
|
+
urls.append(url)
|
|
361
|
+
if not urls:
|
|
362
|
+
continue
|
|
363
|
+
for url in urls:
|
|
364
|
+
try:
|
|
365
|
+
response = requests.post(
|
|
366
|
+
url, data=payload_json, headers=headers, timeout=2
|
|
367
|
+
)
|
|
368
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
369
|
+
logger.debug("Failed to update %s via %s: %s", peer, url, exc)
|
|
370
|
+
continue
|
|
371
|
+
if response.ok:
|
|
372
|
+
version_display = _format_upgrade_body(
|
|
373
|
+
self.installed_version,
|
|
374
|
+
self.installed_revision,
|
|
375
|
+
)
|
|
376
|
+
version_suffix = f" ({version_display})" if version_display else ""
|
|
377
|
+
logger.info(
|
|
378
|
+
"Announced startup to %s%s",
|
|
379
|
+
peer,
|
|
380
|
+
version_suffix,
|
|
381
|
+
)
|
|
382
|
+
break
|
|
383
|
+
else:
|
|
384
|
+
logger.warning("Unable to notify node %s of startup", peer)
|
|
385
|
+
|
|
240
386
|
def ensure_keys(self):
|
|
241
387
|
security_dir = Path(settings.BASE_DIR) / "security"
|
|
242
388
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -526,6 +672,52 @@ class Node(Entity):
|
|
|
526
672
|
)
|
|
527
673
|
|
|
528
674
|
|
|
675
|
+
node_information_updated = Signal()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _format_upgrade_body(version: str, revision: str) -> str:
|
|
679
|
+
version = (version or "").strip()
|
|
680
|
+
revision = (revision or "").strip()
|
|
681
|
+
parts: list[str] = []
|
|
682
|
+
if version:
|
|
683
|
+
normalized = version.lstrip("vV") or version
|
|
684
|
+
parts.append(f"v{normalized}")
|
|
685
|
+
if revision:
|
|
686
|
+
rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
|
|
687
|
+
rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
|
|
688
|
+
parts.append(f"r{rev_short}")
|
|
689
|
+
return " ".join(parts).strip()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@receiver(node_information_updated)
|
|
693
|
+
def _announce_peer_startup(
|
|
694
|
+
sender,
|
|
695
|
+
*,
|
|
696
|
+
node: "Node",
|
|
697
|
+
previous_version: str = "",
|
|
698
|
+
previous_revision: str = "",
|
|
699
|
+
current_version: str = "",
|
|
700
|
+
current_revision: str = "",
|
|
701
|
+
**_: object,
|
|
702
|
+
) -> None:
|
|
703
|
+
current_version = (current_version or "").strip()
|
|
704
|
+
current_revision = (current_revision or "").strip()
|
|
705
|
+
previous_version = (previous_version or "").strip()
|
|
706
|
+
previous_revision = (previous_revision or "").strip()
|
|
707
|
+
|
|
708
|
+
local = Node.get_local()
|
|
709
|
+
if local and node.pk == local.pk:
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
body = _format_upgrade_body(current_version, current_revision)
|
|
713
|
+
if not body:
|
|
714
|
+
body = "Online"
|
|
715
|
+
|
|
716
|
+
hostname = (node.hostname or "Node").strip() or "Node"
|
|
717
|
+
subject = f"UP {hostname}"
|
|
718
|
+
notify_async(subject, body)
|
|
719
|
+
|
|
720
|
+
|
|
529
721
|
class NodeFeatureAssignment(Entity):
|
|
530
722
|
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
531
723
|
|
|
@@ -560,6 +752,243 @@ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
|
560
752
|
node.sync_feature_tasks()
|
|
561
753
|
|
|
562
754
|
|
|
755
|
+
class NodeManager(Profile):
|
|
756
|
+
"""Credentials for interacting with external DNS providers."""
|
|
757
|
+
|
|
758
|
+
class Provider(models.TextChoices):
|
|
759
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
760
|
+
|
|
761
|
+
profile_fields = (
|
|
762
|
+
"provider",
|
|
763
|
+
"api_key",
|
|
764
|
+
"api_secret",
|
|
765
|
+
"customer_id",
|
|
766
|
+
"default_domain",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
provider = models.CharField(
|
|
770
|
+
max_length=20,
|
|
771
|
+
choices=Provider.choices,
|
|
772
|
+
default=Provider.GODADDY,
|
|
773
|
+
)
|
|
774
|
+
api_key = SigilShortAutoField(
|
|
775
|
+
max_length=255,
|
|
776
|
+
help_text="API key issued by the DNS provider.",
|
|
777
|
+
)
|
|
778
|
+
api_secret = SigilShortAutoField(
|
|
779
|
+
max_length=255,
|
|
780
|
+
help_text="API secret issued by the DNS provider.",
|
|
781
|
+
)
|
|
782
|
+
customer_id = SigilShortAutoField(
|
|
783
|
+
max_length=100,
|
|
784
|
+
blank=True,
|
|
785
|
+
help_text="Optional GoDaddy customer identifier for the account.",
|
|
786
|
+
)
|
|
787
|
+
default_domain = SigilShortAutoField(
|
|
788
|
+
max_length=253,
|
|
789
|
+
blank=True,
|
|
790
|
+
help_text="Fallback domain when records omit one.",
|
|
791
|
+
)
|
|
792
|
+
use_sandbox = models.BooleanField(
|
|
793
|
+
default=False,
|
|
794
|
+
help_text="Use the GoDaddy OTE (test) environment.",
|
|
795
|
+
)
|
|
796
|
+
is_enabled = models.BooleanField(
|
|
797
|
+
default=True,
|
|
798
|
+
help_text="Disable to prevent deployments with this manager.",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
class Meta:
|
|
802
|
+
verbose_name = "Node Manager"
|
|
803
|
+
verbose_name_plural = "Node Managers"
|
|
804
|
+
|
|
805
|
+
def __str__(self) -> str:
|
|
806
|
+
owner = self.owner_display()
|
|
807
|
+
provider = self.get_provider_display()
|
|
808
|
+
if owner:
|
|
809
|
+
return f"{provider} ({owner})"
|
|
810
|
+
return provider
|
|
811
|
+
|
|
812
|
+
def clean(self):
|
|
813
|
+
if self.user_id or self.group_id:
|
|
814
|
+
super().clean()
|
|
815
|
+
else:
|
|
816
|
+
super(Profile, self).clean()
|
|
817
|
+
|
|
818
|
+
def get_base_url(self) -> str:
|
|
819
|
+
if self.provider != self.Provider.GODADDY:
|
|
820
|
+
raise ValueError("Unsupported DNS provider")
|
|
821
|
+
if self.use_sandbox:
|
|
822
|
+
return "https://api.ote-godaddy.com"
|
|
823
|
+
return "https://api.godaddy.com"
|
|
824
|
+
|
|
825
|
+
def get_auth_header(self) -> str:
|
|
826
|
+
key = (self.resolve_sigils("api_key") or "").strip()
|
|
827
|
+
secret = (self.resolve_sigils("api_secret") or "").strip()
|
|
828
|
+
if not key or not secret:
|
|
829
|
+
raise ValueError("API credentials are required for DNS deployment")
|
|
830
|
+
return f"sso-key {key}:{secret}"
|
|
831
|
+
|
|
832
|
+
def get_customer_id(self) -> str:
|
|
833
|
+
return (self.resolve_sigils("customer_id") or "").strip()
|
|
834
|
+
|
|
835
|
+
def get_default_domain(self) -> str:
|
|
836
|
+
return (self.resolve_sigils("default_domain") or "").strip()
|
|
837
|
+
|
|
838
|
+
def publish_dns_records(self, records: Iterable["DNSRecord"]):
|
|
839
|
+
from . import dns as dns_utils
|
|
840
|
+
|
|
841
|
+
return dns_utils.deploy_records(self, records)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
class DNSRecord(Entity):
|
|
845
|
+
"""Stored DNS configuration ready for deployment."""
|
|
846
|
+
|
|
847
|
+
class Type(models.TextChoices):
|
|
848
|
+
A = "A", "A"
|
|
849
|
+
AAAA = "AAAA", "AAAA"
|
|
850
|
+
CNAME = "CNAME", "CNAME"
|
|
851
|
+
MX = "MX", "MX"
|
|
852
|
+
NS = "NS", "NS"
|
|
853
|
+
SRV = "SRV", "SRV"
|
|
854
|
+
TXT = "TXT", "TXT"
|
|
855
|
+
|
|
856
|
+
class Provider(models.TextChoices):
|
|
857
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
858
|
+
|
|
859
|
+
provider = models.CharField(
|
|
860
|
+
max_length=20,
|
|
861
|
+
choices=Provider.choices,
|
|
862
|
+
default=Provider.GODADDY,
|
|
863
|
+
)
|
|
864
|
+
node_manager = models.ForeignKey(
|
|
865
|
+
"NodeManager",
|
|
866
|
+
on_delete=models.SET_NULL,
|
|
867
|
+
null=True,
|
|
868
|
+
blank=True,
|
|
869
|
+
related_name="dns_records",
|
|
870
|
+
)
|
|
871
|
+
domain = SigilShortAutoField(
|
|
872
|
+
max_length=253,
|
|
873
|
+
help_text="Base domain such as example.com.",
|
|
874
|
+
)
|
|
875
|
+
name = SigilShortAutoField(
|
|
876
|
+
max_length=253,
|
|
877
|
+
help_text="Record host. Use @ for the zone apex.",
|
|
878
|
+
)
|
|
879
|
+
record_type = models.CharField(
|
|
880
|
+
max_length=10,
|
|
881
|
+
choices=Type.choices,
|
|
882
|
+
default=Type.A,
|
|
883
|
+
verbose_name="Type",
|
|
884
|
+
)
|
|
885
|
+
data = SigilLongAutoField(
|
|
886
|
+
help_text="Record value such as an IP address or hostname.",
|
|
887
|
+
)
|
|
888
|
+
ttl = models.PositiveIntegerField(
|
|
889
|
+
default=600,
|
|
890
|
+
help_text="Time to live in seconds.",
|
|
891
|
+
)
|
|
892
|
+
priority = models.PositiveIntegerField(
|
|
893
|
+
null=True,
|
|
894
|
+
blank=True,
|
|
895
|
+
help_text="Priority for MX and SRV records.",
|
|
896
|
+
)
|
|
897
|
+
port = models.PositiveIntegerField(
|
|
898
|
+
null=True,
|
|
899
|
+
blank=True,
|
|
900
|
+
help_text="Port for SRV records.",
|
|
901
|
+
)
|
|
902
|
+
weight = models.PositiveIntegerField(
|
|
903
|
+
null=True,
|
|
904
|
+
blank=True,
|
|
905
|
+
help_text="Weight for SRV records.",
|
|
906
|
+
)
|
|
907
|
+
service = SigilShortAutoField(
|
|
908
|
+
max_length=50,
|
|
909
|
+
blank=True,
|
|
910
|
+
help_text="Service label for SRV records (for example _sip).",
|
|
911
|
+
)
|
|
912
|
+
protocol = SigilShortAutoField(
|
|
913
|
+
max_length=10,
|
|
914
|
+
blank=True,
|
|
915
|
+
help_text="Protocol label for SRV records (for example _tcp).",
|
|
916
|
+
)
|
|
917
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
918
|
+
last_verified_at = models.DateTimeField(null=True, blank=True)
|
|
919
|
+
last_error = models.TextField(blank=True)
|
|
920
|
+
|
|
921
|
+
class Meta:
|
|
922
|
+
verbose_name = "DNS Record"
|
|
923
|
+
verbose_name_plural = "DNS Records"
|
|
924
|
+
|
|
925
|
+
def __str__(self) -> str:
|
|
926
|
+
return f"{self.record_type} {self.fqdn()}"
|
|
927
|
+
|
|
928
|
+
def get_domain(self, manager: "NodeManager" | None = None) -> str:
|
|
929
|
+
domain = (self.resolve_sigils("domain") or "").strip()
|
|
930
|
+
if domain:
|
|
931
|
+
return domain.rstrip(".")
|
|
932
|
+
if manager:
|
|
933
|
+
fallback = manager.get_default_domain()
|
|
934
|
+
if fallback:
|
|
935
|
+
return fallback.rstrip(".")
|
|
936
|
+
return ""
|
|
937
|
+
|
|
938
|
+
def get_name(self) -> str:
|
|
939
|
+
name = (self.resolve_sigils("name") or "").strip()
|
|
940
|
+
return name or "@"
|
|
941
|
+
|
|
942
|
+
def fqdn(self, manager: "NodeManager" | None = None) -> str:
|
|
943
|
+
domain = self.get_domain(manager)
|
|
944
|
+
name = self.get_name()
|
|
945
|
+
if name in {"@", ""}:
|
|
946
|
+
return domain
|
|
947
|
+
if name.endswith("."):
|
|
948
|
+
return name.rstrip(".")
|
|
949
|
+
if domain:
|
|
950
|
+
return f"{name}.{domain}".rstrip(".")
|
|
951
|
+
return name.rstrip(".")
|
|
952
|
+
|
|
953
|
+
def to_godaddy_payload(self) -> dict[str, object]:
|
|
954
|
+
payload: dict[str, object] = {
|
|
955
|
+
"data": (self.resolve_sigils("data") or "").strip(),
|
|
956
|
+
"ttl": self.ttl,
|
|
957
|
+
}
|
|
958
|
+
if self.priority is not None:
|
|
959
|
+
payload["priority"] = self.priority
|
|
960
|
+
if self.port is not None:
|
|
961
|
+
payload["port"] = self.port
|
|
962
|
+
if self.weight is not None:
|
|
963
|
+
payload["weight"] = self.weight
|
|
964
|
+
service = (self.resolve_sigils("service") or "").strip()
|
|
965
|
+
if service:
|
|
966
|
+
payload["service"] = service
|
|
967
|
+
protocol = (self.resolve_sigils("protocol") or "").strip()
|
|
968
|
+
if protocol:
|
|
969
|
+
payload["protocol"] = protocol
|
|
970
|
+
return payload
|
|
971
|
+
|
|
972
|
+
def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
|
|
973
|
+
if timestamp is None:
|
|
974
|
+
timestamp = timezone.now()
|
|
975
|
+
update_fields = ["last_synced_at", "last_error"]
|
|
976
|
+
self.last_synced_at = timestamp
|
|
977
|
+
self.last_error = ""
|
|
978
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
979
|
+
self.node_manager = manager
|
|
980
|
+
update_fields.append("node_manager")
|
|
981
|
+
self.save(update_fields=update_fields)
|
|
982
|
+
|
|
983
|
+
def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
|
|
984
|
+
update_fields = ["last_error"]
|
|
985
|
+
self.last_error = message
|
|
986
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
987
|
+
self.node_manager = manager
|
|
988
|
+
update_fields.append("node_manager")
|
|
989
|
+
self.save(update_fields=update_fields)
|
|
990
|
+
|
|
991
|
+
|
|
563
992
|
class EmailOutbox(Profile):
|
|
564
993
|
"""SMTP credentials for sending mail."""
|
|
565
994
|
|
|
@@ -612,11 +1041,37 @@ class EmailOutbox(Profile):
|
|
|
612
1041
|
max_length=254,
|
|
613
1042
|
help_text="Default From address; usually the same as username",
|
|
614
1043
|
)
|
|
1044
|
+
is_enabled = models.BooleanField(
|
|
1045
|
+
default=True,
|
|
1046
|
+
help_text="Disable to remove this outbox from automatic selection.",
|
|
1047
|
+
)
|
|
615
1048
|
|
|
616
1049
|
class Meta:
|
|
617
1050
|
verbose_name = "Email Outbox"
|
|
618
1051
|
verbose_name_plural = "Email Outboxes"
|
|
619
1052
|
|
|
1053
|
+
def __str__(self) -> str:
|
|
1054
|
+
address = (self.from_email or "").strip()
|
|
1055
|
+
if address:
|
|
1056
|
+
return address
|
|
1057
|
+
|
|
1058
|
+
username = (self.username or "").strip()
|
|
1059
|
+
host = (self.host or "").strip()
|
|
1060
|
+
if username and host:
|
|
1061
|
+
if "@" in username:
|
|
1062
|
+
return username
|
|
1063
|
+
return f"{username}@{host}"
|
|
1064
|
+
if username:
|
|
1065
|
+
return username
|
|
1066
|
+
if host:
|
|
1067
|
+
return host
|
|
1068
|
+
|
|
1069
|
+
owner = self.owner_display()
|
|
1070
|
+
if owner:
|
|
1071
|
+
return owner
|
|
1072
|
+
|
|
1073
|
+
return super().__str__()
|
|
1074
|
+
|
|
620
1075
|
def clean(self):
|
|
621
1076
|
if self.user_id or self.group_id:
|
|
622
1077
|
super().clean()
|
|
@@ -662,6 +1117,13 @@ class NetMessage(Entity):
|
|
|
662
1117
|
editable=False,
|
|
663
1118
|
verbose_name="UUID",
|
|
664
1119
|
)
|
|
1120
|
+
node_origin = models.ForeignKey(
|
|
1121
|
+
"Node",
|
|
1122
|
+
on_delete=models.SET_NULL,
|
|
1123
|
+
null=True,
|
|
1124
|
+
blank=True,
|
|
1125
|
+
related_name="originated_net_messages",
|
|
1126
|
+
)
|
|
665
1127
|
subject = models.CharField(max_length=64, blank=True)
|
|
666
1128
|
body = models.CharField(max_length=256, blank=True)
|
|
667
1129
|
reach = models.ForeignKey(
|
|
@@ -669,7 +1131,6 @@ class NetMessage(Entity):
|
|
|
669
1131
|
on_delete=models.SET_NULL,
|
|
670
1132
|
null=True,
|
|
671
1133
|
blank=True,
|
|
672
|
-
default=get_terminal_role,
|
|
673
1134
|
)
|
|
674
1135
|
propagated_to = models.ManyToManyField(
|
|
675
1136
|
Node, blank=True, related_name="received_net_messages"
|
|
@@ -696,10 +1157,12 @@ class NetMessage(Entity):
|
|
|
696
1157
|
role = reach
|
|
697
1158
|
else:
|
|
698
1159
|
role = NodeRole.objects.filter(name=reach).first()
|
|
1160
|
+
origin = Node.get_local()
|
|
699
1161
|
msg = cls.objects.create(
|
|
700
1162
|
subject=subject[:64],
|
|
701
1163
|
body=body[:256],
|
|
702
|
-
reach=role
|
|
1164
|
+
reach=role,
|
|
1165
|
+
node_origin=origin,
|
|
703
1166
|
)
|
|
704
1167
|
msg.propagate(seen=seen or [])
|
|
705
1168
|
return msg
|
|
@@ -709,8 +1172,28 @@ class NetMessage(Entity):
|
|
|
709
1172
|
import random
|
|
710
1173
|
import requests
|
|
711
1174
|
|
|
712
|
-
notify(self.subject, self.body)
|
|
1175
|
+
displayed = notify(self.subject, self.body)
|
|
713
1176
|
local = Node.get_local()
|
|
1177
|
+
if displayed:
|
|
1178
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
1179
|
+
prune_qs = type(self).objects.filter(created__lt=cutoff)
|
|
1180
|
+
if local:
|
|
1181
|
+
prune_qs = prune_qs.filter(
|
|
1182
|
+
models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
|
|
1183
|
+
)
|
|
1184
|
+
else:
|
|
1185
|
+
prune_qs = prune_qs.filter(node_origin__isnull=True)
|
|
1186
|
+
if self.pk:
|
|
1187
|
+
prune_qs = prune_qs.exclude(pk=self.pk)
|
|
1188
|
+
prune_qs.delete()
|
|
1189
|
+
if local and not self.node_origin_id:
|
|
1190
|
+
self.node_origin = local
|
|
1191
|
+
self.save(update_fields=["node_origin"])
|
|
1192
|
+
origin_uuid = None
|
|
1193
|
+
if self.node_origin_id:
|
|
1194
|
+
origin_uuid = str(self.node_origin.uuid)
|
|
1195
|
+
elif local:
|
|
1196
|
+
origin_uuid = str(local.uuid)
|
|
714
1197
|
private_key = None
|
|
715
1198
|
seen = list(seen or [])
|
|
716
1199
|
local_id = None
|
|
@@ -749,7 +1232,7 @@ class NetMessage(Entity):
|
|
|
749
1232
|
|
|
750
1233
|
target_limit = min(3, len(remaining))
|
|
751
1234
|
|
|
752
|
-
reach_name = self.reach.name if self.reach else
|
|
1235
|
+
reach_name = self.reach.name if self.reach else None
|
|
753
1236
|
role_map = {
|
|
754
1237
|
"Terminal": ["Terminal"],
|
|
755
1238
|
"Control": ["Control", "Terminal"],
|
|
@@ -761,10 +1244,15 @@ class NetMessage(Entity):
|
|
|
761
1244
|
"Terminal",
|
|
762
1245
|
],
|
|
763
1246
|
}
|
|
764
|
-
role_order = role_map.get(reach_name, [
|
|
1247
|
+
role_order = role_map.get(reach_name, [None])
|
|
765
1248
|
selected: list[Node] = []
|
|
766
1249
|
for role_name in role_order:
|
|
767
|
-
|
|
1250
|
+
if role_name is None:
|
|
1251
|
+
role_nodes = remaining[:]
|
|
1252
|
+
else:
|
|
1253
|
+
role_nodes = [
|
|
1254
|
+
n for n in remaining if n.role and n.role.name == role_name
|
|
1255
|
+
]
|
|
768
1256
|
random.shuffle(role_nodes)
|
|
769
1257
|
for n in role_nodes:
|
|
770
1258
|
selected.append(n)
|
|
@@ -785,6 +1273,7 @@ class NetMessage(Entity):
|
|
|
785
1273
|
"seen": payload_seen,
|
|
786
1274
|
"reach": reach_name,
|
|
787
1275
|
"sender": local_id,
|
|
1276
|
+
"origin": origin_uuid,
|
|
788
1277
|
}
|
|
789
1278
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
790
1279
|
headers = {"Content-Type": "application/json"}
|