arthexis 0.1.12__py3-none-any.whl → 0.1.14__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.

Files changed (107) hide show
  1. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -716
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2772
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2672
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -350
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1511
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1382
  56. core/widgets.py +213 -51
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -898
  60. nodes/apps.py +87 -70
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1416
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -2497
  71. nodes/urls.py +15 -13
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -451
  74. ocpp/admin.py +948 -804
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1342
  77. ocpp/evcs.py +844 -931
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -915
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -9
  82. ocpp/simulator.py +745 -724
  83. ocpp/status_display.py +26 -0
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -3485
  89. ocpp/transactions_io.py +189 -179
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1151
  92. pages/admin.py +708 -536
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -169
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2083
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1120
  104. arthexis-0.1.12.dist-info/RECORD +0 -102
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/models.py CHANGED
@@ -1,1416 +1,1737 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Iterable
4
- from dataclasses import dataclass
5
- from django.db import models
6
- from django.db.utils import DatabaseError
7
- from django.db.models.signals import post_delete
8
- from django.dispatch import Signal, receiver
9
- from core.entity import Entity
10
- from core.models import Profile
11
- from core.fields import SigilLongAutoField, SigilShortAutoField
12
- import re
13
- import json
14
- import base64
15
- from django.utils import timezone
16
- from django.utils.text import slugify
17
- from django.conf import settings
18
- from django.contrib.sites.models import Site
19
- from datetime import timedelta
20
- import uuid
21
- import os
22
- import shutil
23
- import socket
24
- import stat
25
- import subprocess
26
- from pathlib import Path
27
- from utils import revision
28
- from core.notifications import notify_async
29
- from django.core.exceptions import ValidationError
30
- from cryptography.hazmat.primitives.asymmetric import rsa
31
- from cryptography.hazmat.primitives import serialization, hashes
32
- from cryptography.hazmat.primitives.asymmetric import padding
33
- from django.contrib.auth import get_user_model
34
- from django.core.mail import get_connection
35
- from core import mailer
36
- import logging
37
-
38
-
39
- logger = logging.getLogger(__name__)
40
-
41
-
42
- class NodeRoleManager(models.Manager):
43
- def get_by_natural_key(self, name: str):
44
- return self.get(name=name)
45
-
46
-
47
- class NodeRole(Entity):
48
- """Assignable role for a :class:`Node`."""
49
-
50
- name = models.CharField(max_length=50, unique=True)
51
- description = models.CharField(max_length=200, blank=True)
52
-
53
- objects = NodeRoleManager()
54
-
55
- class Meta:
56
- ordering = ["name"]
57
- verbose_name = "Node Role"
58
- verbose_name_plural = "Node Roles"
59
-
60
- def natural_key(self): # pragma: no cover - simple representation
61
- return (self.name,)
62
-
63
- def __str__(self) -> str: # pragma: no cover - simple representation
64
- return self.name
65
-
66
-
67
- class NodeFeatureManager(models.Manager):
68
- def get_by_natural_key(self, slug: str):
69
- return self.get(slug=slug)
70
-
71
-
72
- @dataclass(frozen=True)
73
- class NodeFeatureDefaultAction:
74
- label: str
75
- url_name: str
76
-
77
-
78
- class NodeFeature(Entity):
79
- """Feature that may be enabled on nodes and roles."""
80
-
81
- slug = models.SlugField(max_length=50, unique=True)
82
- display = models.CharField(max_length=50)
83
- roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
84
-
85
- objects = NodeFeatureManager()
86
-
87
- DEFAULT_ACTIONS = {
88
- "rfid-scanner": NodeFeatureDefaultAction(
89
- label="Scan RFIDs", url_name="admin:core_rfid_scan"
90
- ),
91
- "celery-queue": NodeFeatureDefaultAction(
92
- label="Celery Report",
93
- url_name="admin:nodes_nodefeature_celery_report",
94
- ),
95
- "screenshot-poll": NodeFeatureDefaultAction(
96
- label="Take Screenshot",
97
- url_name="admin:nodes_nodefeature_take_screenshot",
98
- ),
99
- "rpi-camera": NodeFeatureDefaultAction(
100
- label="Take a Snapshot",
101
- url_name="admin:nodes_nodefeature_take_snapshot",
102
- ),
103
- }
104
-
105
- class Meta:
106
- ordering = ["display"]
107
- verbose_name = "Node Feature"
108
- verbose_name_plural = "Node Features"
109
-
110
- def natural_key(self): # pragma: no cover - simple representation
111
- return (self.slug,)
112
-
113
- def __str__(self) -> str: # pragma: no cover - simple representation
114
- return self.display
115
-
116
- @property
117
- def is_enabled(self) -> bool:
118
- from django.conf import settings
119
- from pathlib import Path
120
-
121
- node = Node.get_local()
122
- if not node:
123
- return False
124
- if node.features.filter(pk=self.pk).exists():
125
- return True
126
- if self.slug == "gui-toast":
127
- from core.notifications import supports_gui_toast
128
-
129
- return supports_gui_toast()
130
- if self.slug == "rpi-camera":
131
- return Node._has_rpi_camera()
132
- lock_map = {
133
- "lcd-screen": "lcd_screen.lck",
134
- "rfid-scanner": "rfid.lck",
135
- "celery-queue": "celery.lck",
136
- "nginx-server": "nginx_mode.lck",
137
- }
138
- lock = lock_map.get(self.slug)
139
- if lock:
140
- base_path = Path(node.base_path or settings.BASE_DIR)
141
- return (base_path / "locks" / lock).exists()
142
- return False
143
-
144
- def get_default_action(self) -> NodeFeatureDefaultAction | None:
145
- """Return the configured default action for this feature if any."""
146
-
147
- return self.DEFAULT_ACTIONS.get(self.slug)
148
-
149
-
150
- def get_terminal_role():
151
- """Return the NodeRole representing a Terminal if it exists."""
152
- return NodeRole.objects.filter(name="Terminal").first()
153
-
154
-
155
- class Node(Entity):
156
- """Information about a running node in the network."""
157
-
158
- DEFAULT_BADGE_COLOR = "#28a745"
159
- ROLE_BADGE_COLORS = {
160
- "Constellation": "#daa520", # goldenrod
161
- "Control": "#673ab7", # deep purple
162
- }
163
-
164
- class Relation(models.TextChoices):
165
- UPSTREAM = "UPSTREAM", "Upstream"
166
- DOWNSTREAM = "DOWNSTREAM", "Downstream"
167
- PEER = "PEER", "Peer"
168
- SELF = "SELF", "Self"
169
-
170
- hostname = models.CharField(max_length=100)
171
- address = models.GenericIPAddressField()
172
- mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
173
- port = models.PositiveIntegerField(default=8000)
174
- badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
175
- role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
176
- current_relation = models.CharField(
177
- max_length=10,
178
- choices=Relation.choices,
179
- default=Relation.PEER,
180
- )
181
- last_seen = models.DateTimeField(auto_now=True)
182
- enable_public_api = models.BooleanField(
183
- default=False,
184
- verbose_name="enable public API",
185
- )
186
- public_endpoint = models.SlugField(blank=True, unique=True)
187
- uuid = models.UUIDField(
188
- default=uuid.uuid4,
189
- unique=True,
190
- editable=False,
191
- verbose_name="UUID",
192
- )
193
- public_key = models.TextField(blank=True)
194
- base_path = models.CharField(max_length=255, blank=True)
195
- installed_version = models.CharField(max_length=20, blank=True)
196
- installed_revision = models.CharField(max_length=40, blank=True)
197
- features = models.ManyToManyField(
198
- NodeFeature,
199
- through="NodeFeatureAssignment",
200
- related_name="nodes",
201
- blank=True,
202
- )
203
-
204
- FEATURE_LOCK_MAP = {
205
- "lcd-screen": "lcd_screen.lck",
206
- "rfid-scanner": "rfid.lck",
207
- "celery-queue": "celery.lck",
208
- "nginx-server": "nginx_mode.lck",
209
- }
210
- RPI_CAMERA_DEVICE = Path("/dev/video0")
211
- RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
212
- AP_ROUTER_SSID = "gelectriic-ap"
213
- NMCLI_TIMEOUT = 5
214
- AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
215
- "gui-toast",
216
- "rpi-camera",
217
- "ap-router",
218
- "ap-public-wifi",
219
- }
220
- MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
221
-
222
- def __str__(self) -> str: # pragma: no cover - simple representation
223
- return f"{self.hostname}:{self.port}"
224
-
225
- @staticmethod
226
- def get_current_mac() -> str:
227
- """Return the MAC address of the current host."""
228
- return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
229
-
230
- @classmethod
231
- def normalize_relation(cls, value):
232
- """Normalize ``value`` to a valid :class:`Relation`."""
233
-
234
- if isinstance(value, cls.Relation):
235
- return value
236
- if value is None:
237
- return cls.Relation.PEER
238
- text = str(value).strip()
239
- if not text:
240
- return cls.Relation.PEER
241
- for relation in cls.Relation:
242
- if text.lower() == relation.label.lower():
243
- return relation
244
- if text.upper() == relation.name:
245
- return relation
246
- if text.lower() == relation.value.lower():
247
- return relation
248
- return cls.Relation.PEER
249
-
250
- @classmethod
251
- def get_local(cls):
252
- """Return the node representing the current host if it exists."""
253
- mac = cls.get_current_mac()
254
- try:
255
- return cls.objects.filter(mac_address=mac).first()
256
- except DatabaseError:
257
- logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
258
- return None
259
-
260
- @classmethod
261
- def register_current(cls):
262
- """Create or update the :class:`Node` entry for this host."""
263
- hostname = socket.gethostname()
264
- try:
265
- address = socket.gethostbyname(hostname)
266
- except OSError:
267
- address = "127.0.0.1"
268
- port = int(os.environ.get("PORT", 8000))
269
- base_path = str(settings.BASE_DIR)
270
- ver_path = Path(settings.BASE_DIR) / "VERSION"
271
- installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
272
- rev_value = revision.get_revision()
273
- installed_revision = rev_value if rev_value else ""
274
- mac = cls.get_current_mac()
275
- slug = slugify(hostname)
276
- node = cls.objects.filter(mac_address=mac).first()
277
- if not node:
278
- node = cls.objects.filter(public_endpoint=slug).first()
279
- defaults = {
280
- "hostname": hostname,
281
- "address": address,
282
- "port": port,
283
- "base_path": base_path,
284
- "installed_version": installed_version,
285
- "installed_revision": installed_revision,
286
- "public_endpoint": slug,
287
- "mac_address": mac,
288
- "current_relation": cls.Relation.SELF,
289
- }
290
- role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
291
- role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
292
- desired_role = NodeRole.objects.filter(name=role_name).first()
293
-
294
- if node:
295
- update_fields = []
296
- for field, value in defaults.items():
297
- if getattr(node, field) != value:
298
- setattr(node, field, value)
299
- update_fields.append(field)
300
- if desired_role and node.role_id != desired_role.id:
301
- node.role = desired_role
302
- update_fields.append("role")
303
- if update_fields:
304
- node.save(update_fields=update_fields)
305
- created = False
306
- else:
307
- node = cls.objects.create(**defaults)
308
- created = True
309
- if desired_role:
310
- node.role = desired_role
311
- node.save(update_fields=["role"])
312
- if created and node.role is None:
313
- terminal = NodeRole.objects.filter(name="Terminal").first()
314
- if terminal:
315
- node.role = terminal
316
- node.save(update_fields=["role"])
317
- Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
318
- node.ensure_keys()
319
- node.notify_peers_of_update()
320
- return node, created
321
-
322
- def notify_peers_of_update(self):
323
- """Attempt to update this node's registration with known peers."""
324
-
325
- from secrets import token_hex
326
-
327
- try:
328
- import requests
329
- except Exception: # pragma: no cover - requests should be available
330
- return
331
-
332
- security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
333
- priv_path = security_dir / f"{self.public_endpoint}"
334
- if not priv_path.exists():
335
- logger.debug("Private key for %s not found; skipping peer update", self)
336
- return
337
- try:
338
- private_key = serialization.load_pem_private_key(
339
- priv_path.read_bytes(), password=None
340
- )
341
- except Exception as exc: # pragma: no cover - defensive
342
- logger.warning("Failed to load private key for %s: %s", self, exc)
343
- return
344
- token = token_hex(16)
345
- try:
346
- signature = private_key.sign(
347
- token.encode(),
348
- padding.PKCS1v15(),
349
- hashes.SHA256(),
350
- )
351
- except Exception as exc: # pragma: no cover - defensive
352
- logger.warning("Failed to sign peer update for %s: %s", self, exc)
353
- return
354
-
355
- payload = {
356
- "hostname": self.hostname,
357
- "address": self.address,
358
- "port": self.port,
359
- "mac_address": self.mac_address,
360
- "public_key": self.public_key,
361
- "token": token,
362
- "signature": base64.b64encode(signature).decode(),
363
- }
364
- if self.installed_version:
365
- payload["installed_version"] = self.installed_version
366
- if self.installed_revision:
367
- payload["installed_revision"] = self.installed_revision
368
-
369
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
370
- headers = {"Content-Type": "application/json"}
371
-
372
- peers = Node.objects.exclude(pk=self.pk)
373
- for peer in peers:
374
- host_candidates: list[str] = []
375
- if peer.address:
376
- host_candidates.append(peer.address)
377
- if peer.hostname and peer.hostname not in host_candidates:
378
- host_candidates.append(peer.hostname)
379
- port = peer.port or 8000
380
- urls: list[str] = []
381
- for host in host_candidates:
382
- host = host.strip()
383
- if not host:
384
- continue
385
- if ":" in host and not host.startswith("["):
386
- host = f"[{host}]"
387
- http_url = (
388
- f"http://{host}/nodes/register/"
389
- if port == 80
390
- else f"http://{host}:{port}/nodes/register/"
391
- )
392
- https_url = (
393
- f"https://{host}/nodes/register/"
394
- if port in {80, 443}
395
- else f"https://{host}:{port}/nodes/register/"
396
- )
397
- for url in (https_url, http_url):
398
- if url not in urls:
399
- urls.append(url)
400
- if not urls:
401
- continue
402
- for url in urls:
403
- try:
404
- response = requests.post(
405
- url, data=payload_json, headers=headers, timeout=2
406
- )
407
- except Exception as exc: # pragma: no cover - best effort
408
- logger.debug("Failed to update %s via %s: %s", peer, url, exc)
409
- continue
410
- if response.ok:
411
- version_display = _format_upgrade_body(
412
- self.installed_version,
413
- self.installed_revision,
414
- )
415
- version_suffix = f" ({version_display})" if version_display else ""
416
- logger.info(
417
- "Announced startup to %s%s",
418
- peer,
419
- version_suffix,
420
- )
421
- break
422
- else:
423
- logger.warning("Unable to notify node %s of startup", peer)
424
-
425
- def ensure_keys(self):
426
- security_dir = Path(settings.BASE_DIR) / "security"
427
- security_dir.mkdir(parents=True, exist_ok=True)
428
- priv_path = security_dir / f"{self.public_endpoint}"
429
- pub_path = security_dir / f"{self.public_endpoint}.pub"
430
- if not priv_path.exists() or not pub_path.exists():
431
- private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
432
- private_bytes = private_key.private_bytes(
433
- encoding=serialization.Encoding.PEM,
434
- format=serialization.PrivateFormat.TraditionalOpenSSL,
435
- encryption_algorithm=serialization.NoEncryption(),
436
- )
437
- public_bytes = private_key.public_key().public_bytes(
438
- encoding=serialization.Encoding.PEM,
439
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
440
- )
441
- priv_path.write_bytes(private_bytes)
442
- pub_path.write_bytes(public_bytes)
443
- self.public_key = public_bytes.decode()
444
- self.save(update_fields=["public_key"])
445
- elif not self.public_key:
446
- self.public_key = pub_path.read_text()
447
- self.save(update_fields=["public_key"])
448
-
449
- @property
450
- def is_local(self):
451
- """Determine if this node represents the current host."""
452
- return self.mac_address == self.get_current_mac()
453
-
454
- def save(self, *args, **kwargs):
455
- role_name = None
456
- role = getattr(self, "role", None)
457
- if role and getattr(role, "name", None):
458
- role_name = role.name
459
- elif self.role_id:
460
- role_name = (
461
- NodeRole.objects.filter(pk=self.role_id)
462
- .values_list("name", flat=True)
463
- .first()
464
- )
465
-
466
- role_color = self.ROLE_BADGE_COLORS.get(role_name)
467
- if role_color and (
468
- not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
469
- ):
470
- self.badge_color = role_color
471
- update_fields = kwargs.get("update_fields")
472
- if update_fields:
473
- fields = set(update_fields)
474
- if "badge_color" not in fields:
475
- fields.add("badge_color")
476
- kwargs["update_fields"] = tuple(fields)
477
-
478
- if self.mac_address:
479
- self.mac_address = self.mac_address.lower()
480
- if not self.public_endpoint:
481
- self.public_endpoint = slugify(self.hostname)
482
- super().save(*args, **kwargs)
483
- if self.pk:
484
- self.refresh_features()
485
-
486
- def has_feature(self, slug: str) -> bool:
487
- return self.features.filter(slug=slug).exists()
488
-
489
- @classmethod
490
- def _has_rpi_camera(cls) -> bool:
491
- """Return ``True`` when the Raspberry Pi camera stack is available."""
492
-
493
- device = cls.RPI_CAMERA_DEVICE
494
- if not device.exists():
495
- return False
496
- device_path = str(device)
497
- try:
498
- mode = os.stat(device_path).st_mode
499
- except OSError:
500
- return False
501
- if not stat.S_ISCHR(mode):
502
- return False
503
- if not os.access(device_path, os.R_OK | os.W_OK):
504
- return False
505
- for binary in cls.RPI_CAMERA_BINARIES:
506
- tool_path = shutil.which(binary)
507
- if not tool_path:
508
- return False
509
- try:
510
- result = subprocess.run(
511
- [tool_path, "--help"],
512
- capture_output=True,
513
- text=True,
514
- check=False,
515
- timeout=5,
516
- )
517
- except Exception:
518
- return False
519
- if result.returncode != 0:
520
- return False
521
- return True
522
-
523
- @classmethod
524
- def _hosts_gelectriic_ap(cls) -> bool:
525
- """Return ``True`` when the node is hosting the gelectriic access point."""
526
-
527
- nmcli_path = shutil.which("nmcli")
528
- if not nmcli_path:
529
- return False
530
- try:
531
- result = subprocess.run(
532
- [
533
- nmcli_path,
534
- "-t",
535
- "-f",
536
- "NAME,DEVICE,TYPE",
537
- "connection",
538
- "show",
539
- "--active",
540
- ],
541
- capture_output=True,
542
- text=True,
543
- check=False,
544
- timeout=cls.NMCLI_TIMEOUT,
545
- )
546
- except Exception:
547
- return False
548
- if result.returncode != 0:
549
- return False
550
- for line in result.stdout.splitlines():
551
- if not line:
552
- continue
553
- parts = line.split(":", 2)
554
- if not parts:
555
- continue
556
- name = parts[0]
557
- conn_type = ""
558
- if len(parts) == 3:
559
- conn_type = parts[2]
560
- elif len(parts) > 1:
561
- conn_type = parts[1]
562
- if name != cls.AP_ROUTER_SSID:
563
- continue
564
- conn_type_normalized = conn_type.strip().lower()
565
- if conn_type_normalized not in {"wifi", "802-11-wireless"}:
566
- continue
567
- try:
568
- mode_result = subprocess.run(
569
- [
570
- nmcli_path,
571
- "-g",
572
- "802-11-wireless.mode",
573
- "connection",
574
- "show",
575
- name,
576
- ],
577
- capture_output=True,
578
- text=True,
579
- check=False,
580
- timeout=cls.NMCLI_TIMEOUT,
581
- )
582
- except Exception:
583
- continue
584
- if mode_result.returncode != 0:
585
- continue
586
- if mode_result.stdout.strip() == "ap":
587
- return True
588
- return False
589
-
590
- def refresh_features(self):
591
- if not self.pk:
592
- return
593
- if not self.is_local:
594
- self.sync_feature_tasks()
595
- return
596
- detected_slugs = set()
597
- base_path = Path(self.base_path or settings.BASE_DIR)
598
- locks_dir = base_path / "locks"
599
- for slug, filename in self.FEATURE_LOCK_MAP.items():
600
- if (locks_dir / filename).exists():
601
- detected_slugs.add(slug)
602
- if self._has_rpi_camera():
603
- detected_slugs.add("rpi-camera")
604
- public_mode_lock = locks_dir / "public_wifi_mode.lck"
605
- if self._hosts_gelectriic_ap():
606
- if public_mode_lock.exists():
607
- detected_slugs.add("ap-public-wifi")
608
- else:
609
- detected_slugs.add("ap-router")
610
- try:
611
- from core.notifications import supports_gui_toast
612
- except Exception:
613
- pass
614
- else:
615
- try:
616
- if supports_gui_toast():
617
- detected_slugs.add("gui-toast")
618
- except Exception:
619
- pass
620
- current_slugs = set(
621
- self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
622
- "slug", flat=True
623
- )
624
- )
625
- add_slugs = detected_slugs - current_slugs
626
- if add_slugs:
627
- for feature in NodeFeature.objects.filter(slug__in=add_slugs):
628
- NodeFeatureAssignment.objects.update_or_create(
629
- node=self, feature=feature
630
- )
631
- remove_slugs = current_slugs - detected_slugs
632
- if remove_slugs:
633
- NodeFeatureAssignment.objects.filter(
634
- node=self, feature__slug__in=remove_slugs
635
- ).delete()
636
- self.sync_feature_tasks()
637
-
638
- def update_manual_features(self, slugs: Iterable[str]):
639
- desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
640
- remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
641
- if remove_slugs:
642
- NodeFeatureAssignment.objects.filter(
643
- node=self, feature__slug__in=remove_slugs
644
- ).delete()
645
- if desired:
646
- for feature in NodeFeature.objects.filter(slug__in=desired):
647
- NodeFeatureAssignment.objects.update_or_create(
648
- node=self, feature=feature
649
- )
650
- self.sync_feature_tasks()
651
-
652
- def sync_feature_tasks(self):
653
- clipboard_enabled = self.has_feature("clipboard-poll")
654
- screenshot_enabled = self.has_feature("screenshot-poll")
655
- self._sync_clipboard_task(clipboard_enabled)
656
- self._sync_screenshot_task(screenshot_enabled)
657
-
658
- def _sync_clipboard_task(self, enabled: bool):
659
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
660
-
661
- task_name = f"poll_clipboard_node_{self.pk}"
662
- if enabled:
663
- schedule, _ = IntervalSchedule.objects.get_or_create(
664
- every=5, period=IntervalSchedule.SECONDS
665
- )
666
- PeriodicTask.objects.update_or_create(
667
- name=task_name,
668
- defaults={
669
- "interval": schedule,
670
- "task": "nodes.tasks.sample_clipboard",
671
- },
672
- )
673
- else:
674
- PeriodicTask.objects.filter(name=task_name).delete()
675
-
676
- def _sync_screenshot_task(self, enabled: bool):
677
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
678
- import json
679
-
680
- task_name = f"capture_screenshot_node_{self.pk}"
681
- if enabled:
682
- schedule, _ = IntervalSchedule.objects.get_or_create(
683
- every=1, period=IntervalSchedule.MINUTES
684
- )
685
- PeriodicTask.objects.update_or_create(
686
- name=task_name,
687
- defaults={
688
- "interval": schedule,
689
- "task": "nodes.tasks.capture_node_screenshot",
690
- "kwargs": json.dumps(
691
- {
692
- "url": f"http://localhost:{self.port}",
693
- "port": self.port,
694
- "method": "AUTO",
695
- }
696
- ),
697
- },
698
- )
699
- else:
700
- PeriodicTask.objects.filter(name=task_name).delete()
701
-
702
- def send_mail(
703
- self,
704
- subject: str,
705
- message: str,
706
- recipient_list: list[str],
707
- from_email: str | None = None,
708
- **kwargs,
709
- ):
710
- """Send an email using this node's configured outbox if available."""
711
- outbox = getattr(self, "email_outbox", None)
712
- logger.info(
713
- "Node %s queueing email to %s using %s backend",
714
- self.pk,
715
- recipient_list,
716
- "outbox" if outbox else "default",
717
- )
718
- return mailer.send(
719
- subject,
720
- message,
721
- recipient_list,
722
- from_email,
723
- outbox=outbox,
724
- **kwargs,
725
- )
726
-
727
-
728
- node_information_updated = Signal()
729
-
730
-
731
- def _format_upgrade_body(version: str, revision: str) -> str:
732
- version = (version or "").strip()
733
- revision = (revision or "").strip()
734
- parts: list[str] = []
735
- if version:
736
- normalized = version.lstrip("vV") or version
737
- parts.append(f"v{normalized}")
738
- if revision:
739
- rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
740
- rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
741
- parts.append(f"r{rev_short}")
742
- return " ".join(parts).strip()
743
-
744
-
745
- @receiver(node_information_updated)
746
- def _announce_peer_startup(
747
- sender,
748
- *,
749
- node: "Node",
750
- previous_version: str = "",
751
- previous_revision: str = "",
752
- current_version: str = "",
753
- current_revision: str = "",
754
- **_: object,
755
- ) -> None:
756
- current_version = (current_version or "").strip()
757
- current_revision = (current_revision or "").strip()
758
- previous_version = (previous_version or "").strip()
759
- previous_revision = (previous_revision or "").strip()
760
-
761
- local = Node.get_local()
762
- if local and node.pk == local.pk:
763
- return
764
-
765
- body = _format_upgrade_body(current_version, current_revision)
766
- if not body:
767
- body = "Online"
768
-
769
- hostname = (node.hostname or "Node").strip() or "Node"
770
- subject = f"UP {hostname}"
771
- notify_async(subject, body)
772
-
773
-
774
- class NodeFeatureAssignment(Entity):
775
- """Bridge between :class:`Node` and :class:`NodeFeature`."""
776
-
777
- node = models.ForeignKey(
778
- Node, on_delete=models.CASCADE, related_name="feature_assignments"
779
- )
780
- feature = models.ForeignKey(
781
- NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
782
- )
783
- created_at = models.DateTimeField(auto_now_add=True)
784
-
785
- class Meta:
786
- unique_together = ("node", "feature")
787
- verbose_name = "Node Feature Assignment"
788
- verbose_name_plural = "Node Feature Assignments"
789
-
790
- def __str__(self) -> str: # pragma: no cover - simple representation
791
- return f"{self.node} -> {self.feature}"
792
-
793
- def save(self, *args, **kwargs):
794
- super().save(*args, **kwargs)
795
- self.node.sync_feature_tasks()
796
-
797
-
798
- @receiver(post_delete, sender=NodeFeatureAssignment)
799
- def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
800
- node_id = getattr(instance, "node_id", None)
801
- if not node_id:
802
- return
803
- node = Node.objects.filter(pk=node_id).first()
804
- if node:
805
- node.sync_feature_tasks()
806
-
807
-
808
- class NodeManager(Profile):
809
- """Credentials for interacting with external DNS providers."""
810
-
811
- class Provider(models.TextChoices):
812
- GODADDY = "godaddy", "GoDaddy"
813
-
814
- profile_fields = (
815
- "provider",
816
- "api_key",
817
- "api_secret",
818
- "customer_id",
819
- "default_domain",
820
- )
821
-
822
- provider = models.CharField(
823
- max_length=20,
824
- choices=Provider.choices,
825
- default=Provider.GODADDY,
826
- )
827
- api_key = SigilShortAutoField(
828
- max_length=255,
829
- help_text="API key issued by the DNS provider.",
830
- )
831
- api_secret = SigilShortAutoField(
832
- max_length=255,
833
- help_text="API secret issued by the DNS provider.",
834
- )
835
- customer_id = SigilShortAutoField(
836
- max_length=100,
837
- blank=True,
838
- help_text="Optional GoDaddy customer identifier for the account.",
839
- )
840
- default_domain = SigilShortAutoField(
841
- max_length=253,
842
- blank=True,
843
- help_text="Fallback domain when records omit one.",
844
- )
845
- use_sandbox = models.BooleanField(
846
- default=False,
847
- help_text="Use the GoDaddy OTE (test) environment.",
848
- )
849
- is_enabled = models.BooleanField(
850
- default=True,
851
- help_text="Disable to prevent deployments with this manager.",
852
- )
853
-
854
- class Meta:
855
- verbose_name = "Node Manager"
856
- verbose_name_plural = "Node Managers"
857
-
858
- def __str__(self) -> str:
859
- owner = self.owner_display()
860
- provider = self.get_provider_display()
861
- if owner:
862
- return f"{provider} ({owner})"
863
- return provider
864
-
865
- def clean(self):
866
- if self.user_id or self.group_id:
867
- super().clean()
868
- else:
869
- super(Profile, self).clean()
870
-
871
- def get_base_url(self) -> str:
872
- if self.provider != self.Provider.GODADDY:
873
- raise ValueError("Unsupported DNS provider")
874
- if self.use_sandbox:
875
- return "https://api.ote-godaddy.com"
876
- return "https://api.godaddy.com"
877
-
878
- def get_auth_header(self) -> str:
879
- key = (self.resolve_sigils("api_key") or "").strip()
880
- secret = (self.resolve_sigils("api_secret") or "").strip()
881
- if not key or not secret:
882
- raise ValueError("API credentials are required for DNS deployment")
883
- return f"sso-key {key}:{secret}"
884
-
885
- def get_customer_id(self) -> str:
886
- return (self.resolve_sigils("customer_id") or "").strip()
887
-
888
- def get_default_domain(self) -> str:
889
- return (self.resolve_sigils("default_domain") or "").strip()
890
-
891
- def publish_dns_records(self, records: Iterable["DNSRecord"]):
892
- from . import dns as dns_utils
893
-
894
- return dns_utils.deploy_records(self, records)
895
-
896
-
897
- class DNSRecord(Entity):
898
- """Stored DNS configuration ready for deployment."""
899
-
900
- class Type(models.TextChoices):
901
- A = "A", "A"
902
- AAAA = "AAAA", "AAAA"
903
- CNAME = "CNAME", "CNAME"
904
- MX = "MX", "MX"
905
- NS = "NS", "NS"
906
- SRV = "SRV", "SRV"
907
- TXT = "TXT", "TXT"
908
-
909
- class Provider(models.TextChoices):
910
- GODADDY = "godaddy", "GoDaddy"
911
-
912
- provider = models.CharField(
913
- max_length=20,
914
- choices=Provider.choices,
915
- default=Provider.GODADDY,
916
- )
917
- node_manager = models.ForeignKey(
918
- "NodeManager",
919
- on_delete=models.SET_NULL,
920
- null=True,
921
- blank=True,
922
- related_name="dns_records",
923
- )
924
- domain = SigilShortAutoField(
925
- max_length=253,
926
- help_text="Base domain such as example.com.",
927
- )
928
- name = SigilShortAutoField(
929
- max_length=253,
930
- help_text="Record host. Use @ for the zone apex.",
931
- )
932
- record_type = models.CharField(
933
- max_length=10,
934
- choices=Type.choices,
935
- default=Type.A,
936
- verbose_name="Type",
937
- )
938
- data = SigilLongAutoField(
939
- help_text="Record value such as an IP address or hostname.",
940
- )
941
- ttl = models.PositiveIntegerField(
942
- default=600,
943
- help_text="Time to live in seconds.",
944
- )
945
- priority = models.PositiveIntegerField(
946
- null=True,
947
- blank=True,
948
- help_text="Priority for MX and SRV records.",
949
- )
950
- port = models.PositiveIntegerField(
951
- null=True,
952
- blank=True,
953
- help_text="Port for SRV records.",
954
- )
955
- weight = models.PositiveIntegerField(
956
- null=True,
957
- blank=True,
958
- help_text="Weight for SRV records.",
959
- )
960
- service = SigilShortAutoField(
961
- max_length=50,
962
- blank=True,
963
- help_text="Service label for SRV records (for example _sip).",
964
- )
965
- protocol = SigilShortAutoField(
966
- max_length=10,
967
- blank=True,
968
- help_text="Protocol label for SRV records (for example _tcp).",
969
- )
970
- last_synced_at = models.DateTimeField(null=True, blank=True)
971
- last_verified_at = models.DateTimeField(null=True, blank=True)
972
- last_error = models.TextField(blank=True)
973
-
974
- class Meta:
975
- verbose_name = "DNS Record"
976
- verbose_name_plural = "DNS Records"
977
-
978
- def __str__(self) -> str:
979
- return f"{self.record_type} {self.fqdn()}"
980
-
981
- def get_domain(self, manager: "NodeManager" | None = None) -> str:
982
- domain = (self.resolve_sigils("domain") or "").strip()
983
- if domain:
984
- return domain.rstrip(".")
985
- if manager:
986
- fallback = manager.get_default_domain()
987
- if fallback:
988
- return fallback.rstrip(".")
989
- return ""
990
-
991
- def get_name(self) -> str:
992
- name = (self.resolve_sigils("name") or "").strip()
993
- return name or "@"
994
-
995
- def fqdn(self, manager: "NodeManager" | None = None) -> str:
996
- domain = self.get_domain(manager)
997
- name = self.get_name()
998
- if name in {"@", ""}:
999
- return domain
1000
- if name.endswith("."):
1001
- return name.rstrip(".")
1002
- if domain:
1003
- return f"{name}.{domain}".rstrip(".")
1004
- return name.rstrip(".")
1005
-
1006
- def to_godaddy_payload(self) -> dict[str, object]:
1007
- payload: dict[str, object] = {
1008
- "data": (self.resolve_sigils("data") or "").strip(),
1009
- "ttl": self.ttl,
1010
- }
1011
- if self.priority is not None:
1012
- payload["priority"] = self.priority
1013
- if self.port is not None:
1014
- payload["port"] = self.port
1015
- if self.weight is not None:
1016
- payload["weight"] = self.weight
1017
- service = (self.resolve_sigils("service") or "").strip()
1018
- if service:
1019
- payload["service"] = service
1020
- protocol = (self.resolve_sigils("protocol") or "").strip()
1021
- if protocol:
1022
- payload["protocol"] = protocol
1023
- return payload
1024
-
1025
- def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
1026
- if timestamp is None:
1027
- timestamp = timezone.now()
1028
- update_fields = ["last_synced_at", "last_error"]
1029
- self.last_synced_at = timestamp
1030
- self.last_error = ""
1031
- if manager and self.node_manager_id != getattr(manager, "pk", None):
1032
- self.node_manager = manager
1033
- update_fields.append("node_manager")
1034
- self.save(update_fields=update_fields)
1035
-
1036
- def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
1037
- update_fields = ["last_error"]
1038
- self.last_error = message
1039
- if manager and self.node_manager_id != getattr(manager, "pk", None):
1040
- self.node_manager = manager
1041
- update_fields.append("node_manager")
1042
- self.save(update_fields=update_fields)
1043
-
1044
-
1045
- class EmailOutbox(Profile):
1046
- """SMTP credentials for sending mail."""
1047
-
1048
- profile_fields = (
1049
- "host",
1050
- "port",
1051
- "username",
1052
- "password",
1053
- "use_tls",
1054
- "use_ssl",
1055
- "from_email",
1056
- )
1057
-
1058
- node = models.OneToOneField(
1059
- Node,
1060
- on_delete=models.CASCADE,
1061
- related_name="email_outbox",
1062
- null=True,
1063
- blank=True,
1064
- )
1065
- host = SigilShortAutoField(
1066
- max_length=100,
1067
- help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
1068
- )
1069
- port = models.PositiveIntegerField(
1070
- default=587,
1071
- help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
1072
- )
1073
- username = SigilShortAutoField(
1074
- max_length=100,
1075
- blank=True,
1076
- help_text="Full email address for Gmail or GoDaddy",
1077
- )
1078
- password = SigilShortAutoField(
1079
- max_length=100,
1080
- blank=True,
1081
- help_text="Email account password or app password",
1082
- )
1083
- use_tls = models.BooleanField(
1084
- default=True,
1085
- help_text="Check for Gmail or GoDaddy on port 587",
1086
- )
1087
- use_ssl = models.BooleanField(
1088
- default=False,
1089
- help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
1090
- )
1091
- from_email = SigilShortAutoField(
1092
- blank=True,
1093
- verbose_name="From Email",
1094
- max_length=254,
1095
- help_text="Default From address; usually the same as username",
1096
- )
1097
- is_enabled = models.BooleanField(
1098
- default=True,
1099
- help_text="Disable to remove this outbox from automatic selection.",
1100
- )
1101
-
1102
- class Meta:
1103
- verbose_name = "Email Outbox"
1104
- verbose_name_plural = "Email Outboxes"
1105
-
1106
- def __str__(self) -> str:
1107
- address = (self.from_email or "").strip()
1108
- if address:
1109
- return address
1110
-
1111
- username = (self.username or "").strip()
1112
- host = (self.host or "").strip()
1113
- if username:
1114
- local, sep, domain = username.partition("@")
1115
- if sep and domain:
1116
- return username
1117
- if host:
1118
- sanitized = username.rstrip("@")
1119
- if sanitized:
1120
- return f"{sanitized}@{host}"
1121
- return host
1122
- return username
1123
- if host:
1124
- return host
1125
-
1126
- owner = self.owner_display()
1127
- if owner:
1128
- return owner
1129
-
1130
- return super().__str__()
1131
-
1132
- def clean(self):
1133
- if self.user_id or self.group_id:
1134
- super().clean()
1135
- else:
1136
- super(Profile, self).clean()
1137
-
1138
- def get_connection(self):
1139
- return get_connection(
1140
- "django.core.mail.backends.smtp.EmailBackend",
1141
- host=self.host,
1142
- port=self.port,
1143
- username=self.username or None,
1144
- password=self.password or None,
1145
- use_tls=self.use_tls,
1146
- use_ssl=self.use_ssl,
1147
- )
1148
-
1149
- def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
1150
- from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
1151
- logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
1152
- return mailer.send(
1153
- subject,
1154
- message,
1155
- recipient_list,
1156
- from_email,
1157
- outbox=self,
1158
- **kwargs,
1159
- )
1160
-
1161
- def owner_display(self):
1162
- owner = super().owner_display()
1163
- if owner:
1164
- return owner
1165
- return str(self.node) if self.node_id else ""
1166
-
1167
-
1168
- class NetMessage(Entity):
1169
- """Message propagated across nodes."""
1170
-
1171
- uuid = models.UUIDField(
1172
- default=uuid.uuid4,
1173
- unique=True,
1174
- editable=False,
1175
- verbose_name="UUID",
1176
- )
1177
- node_origin = models.ForeignKey(
1178
- "Node",
1179
- on_delete=models.SET_NULL,
1180
- null=True,
1181
- blank=True,
1182
- related_name="originated_net_messages",
1183
- )
1184
- subject = models.CharField(max_length=64, blank=True)
1185
- body = models.CharField(max_length=256, blank=True)
1186
- reach = models.ForeignKey(
1187
- NodeRole,
1188
- on_delete=models.SET_NULL,
1189
- null=True,
1190
- blank=True,
1191
- )
1192
- propagated_to = models.ManyToManyField(
1193
- Node, blank=True, related_name="received_net_messages"
1194
- )
1195
- created = models.DateTimeField(auto_now_add=True)
1196
- complete = models.BooleanField(default=False, editable=False)
1197
-
1198
- class Meta:
1199
- ordering = ["-created"]
1200
- verbose_name = "Net Message"
1201
- verbose_name_plural = "Net Messages"
1202
-
1203
- @classmethod
1204
- def broadcast(
1205
- cls,
1206
- subject: str,
1207
- body: str,
1208
- reach: NodeRole | str | None = None,
1209
- seen: list[str] | None = None,
1210
- ):
1211
- role = None
1212
- if reach:
1213
- if isinstance(reach, NodeRole):
1214
- role = reach
1215
- else:
1216
- role = NodeRole.objects.filter(name=reach).first()
1217
- origin = Node.get_local()
1218
- msg = cls.objects.create(
1219
- subject=subject[:64],
1220
- body=body[:256],
1221
- reach=role,
1222
- node_origin=origin,
1223
- )
1224
- msg.propagate(seen=seen or [])
1225
- return msg
1226
-
1227
- def propagate(self, seen: list[str] | None = None):
1228
- from core.notifications import notify
1229
- import random
1230
- import requests
1231
-
1232
- displayed = notify(self.subject, self.body)
1233
- local = Node.get_local()
1234
- if displayed:
1235
- cutoff = timezone.now() - timedelta(days=7)
1236
- prune_qs = type(self).objects.filter(created__lt=cutoff)
1237
- if local:
1238
- prune_qs = prune_qs.filter(
1239
- models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
1240
- )
1241
- else:
1242
- prune_qs = prune_qs.filter(node_origin__isnull=True)
1243
- if self.pk:
1244
- prune_qs = prune_qs.exclude(pk=self.pk)
1245
- prune_qs.delete()
1246
- if local and not self.node_origin_id:
1247
- self.node_origin = local
1248
- self.save(update_fields=["node_origin"])
1249
- origin_uuid = None
1250
- if self.node_origin_id:
1251
- origin_uuid = str(self.node_origin.uuid)
1252
- elif local:
1253
- origin_uuid = str(local.uuid)
1254
- private_key = None
1255
- seen = list(seen or [])
1256
- local_id = None
1257
- if local:
1258
- local_id = str(local.uuid)
1259
- if local_id not in seen:
1260
- seen.append(local_id)
1261
- priv_path = (
1262
- Path(local.base_path or settings.BASE_DIR)
1263
- / "security"
1264
- / f"{local.public_endpoint}"
1265
- )
1266
- try:
1267
- private_key = serialization.load_pem_private_key(
1268
- priv_path.read_bytes(), password=None
1269
- )
1270
- except Exception:
1271
- private_key = None
1272
- for node_id in seen:
1273
- node = Node.objects.filter(uuid=node_id).first()
1274
- if node and (not local or node.pk != local.pk):
1275
- self.propagated_to.add(node)
1276
-
1277
- all_nodes = Node.objects.all()
1278
- if local:
1279
- all_nodes = all_nodes.exclude(pk=local.pk)
1280
- total_known = all_nodes.count()
1281
-
1282
- remaining = list(
1283
- all_nodes.exclude(pk__in=self.propagated_to.values_list("pk", flat=True))
1284
- )
1285
- if not remaining:
1286
- self.complete = True
1287
- self.save(update_fields=["complete"])
1288
- return
1289
-
1290
- target_limit = min(3, len(remaining))
1291
-
1292
- reach_name = self.reach.name if self.reach else None
1293
- role_map = {
1294
- "Terminal": ["Terminal"],
1295
- "Control": ["Control", "Terminal"],
1296
- "Satellite": ["Satellite", "Control", "Terminal"],
1297
- "Constellation": [
1298
- "Constellation",
1299
- "Satellite",
1300
- "Control",
1301
- "Terminal",
1302
- ],
1303
- }
1304
- role_order = role_map.get(reach_name, [None])
1305
- selected: list[Node] = []
1306
- for role_name in role_order:
1307
- if role_name is None:
1308
- role_nodes = remaining[:]
1309
- else:
1310
- role_nodes = [
1311
- n for n in remaining if n.role and n.role.name == role_name
1312
- ]
1313
- random.shuffle(role_nodes)
1314
- for n in role_nodes:
1315
- selected.append(n)
1316
- remaining.remove(n)
1317
- if len(selected) >= target_limit:
1318
- break
1319
- if len(selected) >= target_limit:
1320
- break
1321
-
1322
- seen_list = seen.copy()
1323
- selected_ids = [str(n.uuid) for n in selected]
1324
- payload_seen = seen_list + selected_ids
1325
- for node in selected:
1326
- payload = {
1327
- "uuid": str(self.uuid),
1328
- "subject": self.subject,
1329
- "body": self.body,
1330
- "seen": payload_seen,
1331
- "reach": reach_name,
1332
- "sender": local_id,
1333
- "origin": origin_uuid,
1334
- }
1335
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1336
- headers = {"Content-Type": "application/json"}
1337
- if private_key:
1338
- try:
1339
- signature = private_key.sign(
1340
- payload_json.encode(),
1341
- padding.PKCS1v15(),
1342
- hashes.SHA256(),
1343
- )
1344
- headers["X-Signature"] = base64.b64encode(signature).decode()
1345
- except Exception:
1346
- pass
1347
- try:
1348
- requests.post(
1349
- f"http://{node.address}:{node.port}/nodes/net-message/",
1350
- data=payload_json,
1351
- headers=headers,
1352
- timeout=1,
1353
- )
1354
- except Exception:
1355
- pass
1356
- self.propagated_to.add(node)
1357
-
1358
- if total_known and self.propagated_to.count() >= total_known:
1359
- self.complete = True
1360
- self.save(update_fields=["complete"] if self.complete else [])
1361
-
1362
-
1363
- class ContentSample(Entity):
1364
- """Collected content such as text snippets or screenshots."""
1365
-
1366
- TEXT = "TEXT"
1367
- IMAGE = "IMAGE"
1368
- KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
1369
-
1370
- name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
1371
- kind = models.CharField(max_length=10, choices=KIND_CHOICES)
1372
- content = models.TextField(blank=True)
1373
- path = models.CharField(max_length=255, blank=True)
1374
- method = models.CharField(max_length=10, default="", blank=True)
1375
- hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
1376
- transaction_uuid = models.UUIDField(
1377
- default=uuid.uuid4,
1378
- editable=True,
1379
- db_index=True,
1380
- verbose_name="transaction UUID",
1381
- )
1382
- node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
1383
- user = models.ForeignKey(
1384
- settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
1385
- )
1386
- created_at = models.DateTimeField(auto_now_add=True)
1387
-
1388
- class Meta:
1389
- ordering = ["-created_at"]
1390
- verbose_name = "Content Sample"
1391
- verbose_name_plural = "Content Samples"
1392
-
1393
- def save(self, *args, **kwargs):
1394
- if self.pk:
1395
- original = type(self).all_objects.get(pk=self.pk)
1396
- if original.transaction_uuid != self.transaction_uuid:
1397
- raise ValidationError(
1398
- {"transaction_uuid": "Cannot modify transaction UUID"}
1399
- )
1400
- if self.node_id is None:
1401
- self.node = Node.get_local()
1402
- super().save(*args, **kwargs)
1403
-
1404
- def __str__(self) -> str: # pragma: no cover - simple representation
1405
- return str(self.name)
1406
-
1407
-
1408
- UserModel = get_user_model()
1409
-
1410
-
1411
- class User(UserModel):
1412
- class Meta:
1413
- proxy = True
1414
- app_label = "nodes"
1415
- verbose_name = UserModel._meta.verbose_name
1416
- verbose_name_plural = UserModel._meta.verbose_name_plural
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ from django.db import models
7
+ from django.db.utils import DatabaseError
8
+ from django.db.models.signals import post_delete
9
+ from django.dispatch import Signal, receiver
10
+ from core.entity import Entity
11
+ from core.models import PackageRelease, Profile
12
+ from core.fields import SigilLongAutoField, SigilShortAutoField
13
+ import re
14
+ import json
15
+ import base64
16
+ from django.utils import timezone
17
+ from django.utils.text import slugify
18
+ from django.conf import settings
19
+ from django.contrib.sites.models import Site
20
+ from datetime import timedelta
21
+ import uuid
22
+ import os
23
+ import shutil
24
+ import socket
25
+ import stat
26
+ import subprocess
27
+ from pathlib import Path
28
+ from utils import revision
29
+ from core.notifications import notify_async
30
+ from django.core.exceptions import ValidationError
31
+ from cryptography.hazmat.primitives.asymmetric import rsa
32
+ from cryptography.hazmat.primitives import serialization, hashes
33
+ from cryptography.hazmat.primitives.asymmetric import padding
34
+ from django.contrib.auth import get_user_model
35
+ from django.core import serializers
36
+ from django.core.mail import get_connection
37
+ from django.core.serializers.base import DeserializationError
38
+ from core import mailer
39
+ import logging
40
+
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class NodeRoleManager(models.Manager):
46
+ def get_by_natural_key(self, name: str):
47
+ return self.get(name=name)
48
+
49
+
50
+ class NodeRole(Entity):
51
+ """Assignable role for a :class:`Node`."""
52
+
53
+ name = models.CharField(max_length=50, unique=True)
54
+ description = models.CharField(max_length=200, blank=True)
55
+
56
+ objects = NodeRoleManager()
57
+
58
+ class Meta:
59
+ ordering = ["name"]
60
+ verbose_name = "Node Role"
61
+ verbose_name_plural = "Node Roles"
62
+
63
+ def natural_key(self): # pragma: no cover - simple representation
64
+ return (self.name,)
65
+
66
+ def __str__(self) -> str: # pragma: no cover - simple representation
67
+ return self.name
68
+
69
+
70
+ class NodeFeatureManager(models.Manager):
71
+ def get_by_natural_key(self, slug: str):
72
+ return self.get(slug=slug)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class NodeFeatureDefaultAction:
77
+ label: str
78
+ url_name: str
79
+
80
+
81
+ class NodeFeature(Entity):
82
+ """Feature that may be enabled on nodes and roles."""
83
+
84
+ slug = models.SlugField(max_length=50, unique=True)
85
+ display = models.CharField(max_length=50)
86
+ roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
87
+
88
+ objects = NodeFeatureManager()
89
+
90
+ DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
91
+ "rfid-scanner": (
92
+ NodeFeatureDefaultAction(
93
+ label="Scan RFIDs", url_name="admin:core_rfid_scan"
94
+ ),
95
+ ),
96
+ "celery-queue": (
97
+ NodeFeatureDefaultAction(
98
+ label="Celery Report",
99
+ url_name="admin:nodes_nodefeature_celery_report",
100
+ ),
101
+ ),
102
+ "screenshot-poll": (
103
+ NodeFeatureDefaultAction(
104
+ label="Take Screenshot",
105
+ url_name="admin:nodes_nodefeature_take_screenshot",
106
+ ),
107
+ ),
108
+ "rpi-camera": (
109
+ NodeFeatureDefaultAction(
110
+ label="Take a Snapshot",
111
+ url_name="admin:nodes_nodefeature_take_snapshot",
112
+ ),
113
+ NodeFeatureDefaultAction(
114
+ label="View stream",
115
+ url_name="admin:nodes_nodefeature_view_stream",
116
+ ),
117
+ ),
118
+ }
119
+
120
+ class Meta:
121
+ ordering = ["display"]
122
+ verbose_name = "Node Feature"
123
+ verbose_name_plural = "Node Features"
124
+
125
+ def natural_key(self): # pragma: no cover - simple representation
126
+ return (self.slug,)
127
+
128
+ def __str__(self) -> str: # pragma: no cover - simple representation
129
+ return self.display
130
+
131
+ @property
132
+ def is_enabled(self) -> bool:
133
+ from django.conf import settings
134
+ from pathlib import Path
135
+
136
+ node = Node.get_local()
137
+ if not node:
138
+ return False
139
+ if node.features.filter(pk=self.pk).exists():
140
+ return True
141
+ if self.slug == "gway-runner":
142
+ return Node._has_gway_runner()
143
+ if self.slug == "gui-toast":
144
+ from core.notifications import supports_gui_toast
145
+
146
+ return supports_gui_toast()
147
+ if self.slug == "rpi-camera":
148
+ return Node._has_rpi_camera()
149
+ lock_map = {
150
+ "lcd-screen": "lcd_screen.lck",
151
+ "rfid-scanner": "rfid.lck",
152
+ "celery-queue": "celery.lck",
153
+ "nginx-server": "nginx_mode.lck",
154
+ }
155
+ lock = lock_map.get(self.slug)
156
+ if lock:
157
+ base_path = Path(node.base_path or settings.BASE_DIR)
158
+ return (base_path / "locks" / lock).exists()
159
+ return False
160
+
161
+ def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
162
+ """Return the configured default actions for this feature."""
163
+
164
+ actions = self.DEFAULT_ACTIONS.get(self.slug, ())
165
+ if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
166
+ return (actions,)
167
+ return actions
168
+
169
+ def get_default_action(self) -> NodeFeatureDefaultAction | None:
170
+ """Return the first configured default action for this feature if any."""
171
+
172
+ actions = self.get_default_actions()
173
+ return actions[0] if actions else None
174
+
175
+
176
+ def get_terminal_role():
177
+ """Return the NodeRole representing a Terminal if it exists."""
178
+ return NodeRole.objects.filter(name="Terminal").first()
179
+
180
+
181
+ class Node(Entity):
182
+ """Information about a running node in the network."""
183
+
184
+ DEFAULT_BADGE_COLOR = "#28a745"
185
+ ROLE_BADGE_COLORS = {
186
+ "Constellation": "#daa520", # goldenrod
187
+ "Control": "#673ab7", # deep purple
188
+ }
189
+
190
+ class Relation(models.TextChoices):
191
+ UPSTREAM = "UPSTREAM", "Upstream"
192
+ DOWNSTREAM = "DOWNSTREAM", "Downstream"
193
+ PEER = "PEER", "Peer"
194
+ SELF = "SELF", "Self"
195
+
196
+ hostname = models.CharField(max_length=100)
197
+ address = models.GenericIPAddressField()
198
+ mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
199
+ port = models.PositiveIntegerField(default=8000)
200
+ badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
201
+ role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
202
+ current_relation = models.CharField(
203
+ max_length=10,
204
+ choices=Relation.choices,
205
+ default=Relation.PEER,
206
+ )
207
+ last_seen = models.DateTimeField(auto_now=True)
208
+ enable_public_api = models.BooleanField(
209
+ default=False,
210
+ verbose_name="enable public API",
211
+ )
212
+ public_endpoint = models.SlugField(blank=True, unique=True)
213
+ uuid = models.UUIDField(
214
+ default=uuid.uuid4,
215
+ unique=True,
216
+ editable=False,
217
+ verbose_name="UUID",
218
+ )
219
+ public_key = models.TextField(blank=True)
220
+ base_path = models.CharField(max_length=255, blank=True)
221
+ installed_version = models.CharField(max_length=20, blank=True)
222
+ installed_revision = models.CharField(max_length=40, blank=True)
223
+ features = models.ManyToManyField(
224
+ NodeFeature,
225
+ through="NodeFeatureAssignment",
226
+ related_name="nodes",
227
+ blank=True,
228
+ )
229
+
230
+ FEATURE_LOCK_MAP = {
231
+ "lcd-screen": "lcd_screen.lck",
232
+ "rfid-scanner": "rfid.lck",
233
+ "celery-queue": "celery.lck",
234
+ "nginx-server": "nginx_mode.lck",
235
+ }
236
+ RPI_CAMERA_DEVICE = Path("/dev/video0")
237
+ RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
238
+ AP_ROUTER_SSID = "gelectriic-ap"
239
+ NMCLI_TIMEOUT = 5
240
+ GWAY_RUNNER_COMMAND = "gway"
241
+ GWAY_RUNNER_CANDIDATES = ("~/.local/bin/gway", "/usr/local/bin/gway")
242
+ AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
243
+ "gui-toast",
244
+ "rpi-camera",
245
+ "ap-router",
246
+ "gway-runner",
247
+ }
248
+ MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
249
+
250
+ def __str__(self) -> str: # pragma: no cover - simple representation
251
+ return f"{self.hostname}:{self.port}"
252
+
253
+ @staticmethod
254
+ def get_current_mac() -> str:
255
+ """Return the MAC address of the current host."""
256
+ return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
257
+
258
+ @classmethod
259
+ def normalize_relation(cls, value):
260
+ """Normalize ``value`` to a valid :class:`Relation`."""
261
+
262
+ if isinstance(value, cls.Relation):
263
+ return value
264
+ if value is None:
265
+ return cls.Relation.PEER
266
+ text = str(value).strip()
267
+ if not text:
268
+ return cls.Relation.PEER
269
+ for relation in cls.Relation:
270
+ if text.lower() == relation.label.lower():
271
+ return relation
272
+ if text.upper() == relation.name:
273
+ return relation
274
+ if text.lower() == relation.value.lower():
275
+ return relation
276
+ return cls.Relation.PEER
277
+
278
+ @classmethod
279
+ def get_local(cls):
280
+ """Return the node representing the current host if it exists."""
281
+ mac = cls.get_current_mac()
282
+ try:
283
+ return cls.objects.filter(mac_address=mac).first()
284
+ except DatabaseError:
285
+ logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
286
+ return None
287
+
288
+ @classmethod
289
+ def register_current(cls):
290
+ """Create or update the :class:`Node` entry for this host."""
291
+ hostname = socket.gethostname()
292
+ try:
293
+ address = socket.gethostbyname(hostname)
294
+ except OSError:
295
+ address = "127.0.0.1"
296
+ port = int(os.environ.get("PORT", 8000))
297
+ base_path = str(settings.BASE_DIR)
298
+ ver_path = Path(settings.BASE_DIR) / "VERSION"
299
+ installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
300
+ rev_value = revision.get_revision()
301
+ installed_revision = rev_value if rev_value else ""
302
+ mac = cls.get_current_mac()
303
+ slug = slugify(hostname)
304
+ node = cls.objects.filter(mac_address=mac).first()
305
+ if not node:
306
+ node = cls.objects.filter(public_endpoint=slug).first()
307
+ defaults = {
308
+ "hostname": hostname,
309
+ "address": address,
310
+ "port": port,
311
+ "base_path": base_path,
312
+ "installed_version": installed_version,
313
+ "installed_revision": installed_revision,
314
+ "public_endpoint": slug,
315
+ "mac_address": mac,
316
+ "current_relation": cls.Relation.SELF,
317
+ }
318
+ role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
319
+ role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
320
+ desired_role = NodeRole.objects.filter(name=role_name).first()
321
+
322
+ if node:
323
+ update_fields = []
324
+ for field, value in defaults.items():
325
+ if getattr(node, field) != value:
326
+ setattr(node, field, value)
327
+ update_fields.append(field)
328
+ if desired_role and node.role_id != desired_role.id:
329
+ node.role = desired_role
330
+ update_fields.append("role")
331
+ if update_fields:
332
+ node.save(update_fields=update_fields)
333
+ else:
334
+ node.refresh_features()
335
+ created = False
336
+ else:
337
+ node = cls.objects.create(**defaults)
338
+ created = True
339
+ if desired_role:
340
+ node.role = desired_role
341
+ node.save(update_fields=["role"])
342
+ if created and node.role is None:
343
+ terminal = NodeRole.objects.filter(name="Terminal").first()
344
+ if terminal:
345
+ node.role = terminal
346
+ node.save(update_fields=["role"])
347
+ Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
348
+ node.ensure_keys()
349
+ node.notify_peers_of_update()
350
+ return node, created
351
+
352
+ def notify_peers_of_update(self):
353
+ """Attempt to update this node's registration with known peers."""
354
+
355
+ from secrets import token_hex
356
+
357
+ try:
358
+ import requests
359
+ except Exception: # pragma: no cover - requests should be available
360
+ return
361
+
362
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
363
+ priv_path = security_dir / f"{self.public_endpoint}"
364
+ if not priv_path.exists():
365
+ logger.debug("Private key for %s not found; skipping peer update", self)
366
+ return
367
+ try:
368
+ private_key = serialization.load_pem_private_key(
369
+ priv_path.read_bytes(), password=None
370
+ )
371
+ except Exception as exc: # pragma: no cover - defensive
372
+ logger.warning("Failed to load private key for %s: %s", self, exc)
373
+ return
374
+ token = token_hex(16)
375
+ try:
376
+ signature = private_key.sign(
377
+ token.encode(),
378
+ padding.PKCS1v15(),
379
+ hashes.SHA256(),
380
+ )
381
+ except Exception as exc: # pragma: no cover - defensive
382
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
383
+ return
384
+
385
+ payload = {
386
+ "hostname": self.hostname,
387
+ "address": self.address,
388
+ "port": self.port,
389
+ "mac_address": self.mac_address,
390
+ "public_key": self.public_key,
391
+ "token": token,
392
+ "signature": base64.b64encode(signature).decode(),
393
+ }
394
+ if self.installed_version:
395
+ payload["installed_version"] = self.installed_version
396
+ if self.installed_revision:
397
+ payload["installed_revision"] = self.installed_revision
398
+
399
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
400
+ headers = {"Content-Type": "application/json"}
401
+
402
+ peers = Node.objects.exclude(pk=self.pk)
403
+ for peer in peers:
404
+ host_candidates: list[str] = []
405
+ if peer.address:
406
+ host_candidates.append(peer.address)
407
+ if peer.hostname and peer.hostname not in host_candidates:
408
+ host_candidates.append(peer.hostname)
409
+ port = peer.port or 8000
410
+ urls: list[str] = []
411
+ for host in host_candidates:
412
+ host = host.strip()
413
+ if not host:
414
+ continue
415
+ if ":" in host and not host.startswith("["):
416
+ host = f"[{host}]"
417
+ http_url = (
418
+ f"http://{host}/nodes/register/"
419
+ if port == 80
420
+ else f"http://{host}:{port}/nodes/register/"
421
+ )
422
+ https_url = (
423
+ f"https://{host}/nodes/register/"
424
+ if port in {80, 443}
425
+ else f"https://{host}:{port}/nodes/register/"
426
+ )
427
+ for url in (https_url, http_url):
428
+ if url not in urls:
429
+ urls.append(url)
430
+ if not urls:
431
+ continue
432
+ for url in urls:
433
+ try:
434
+ response = requests.post(
435
+ url, data=payload_json, headers=headers, timeout=2
436
+ )
437
+ except Exception as exc: # pragma: no cover - best effort
438
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
439
+ continue
440
+ if response.ok:
441
+ version_display = _format_upgrade_body(
442
+ self.installed_version,
443
+ self.installed_revision,
444
+ )
445
+ version_suffix = f" ({version_display})" if version_display else ""
446
+ logger.info(
447
+ "Announced startup to %s%s",
448
+ peer,
449
+ version_suffix,
450
+ )
451
+ break
452
+ else:
453
+ logger.warning("Unable to notify node %s of startup", peer)
454
+
455
+ def ensure_keys(self):
456
+ security_dir = Path(settings.BASE_DIR) / "security"
457
+ security_dir.mkdir(parents=True, exist_ok=True)
458
+ priv_path = security_dir / f"{self.public_endpoint}"
459
+ pub_path = security_dir / f"{self.public_endpoint}.pub"
460
+ if not priv_path.exists() or not pub_path.exists():
461
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
462
+ private_bytes = private_key.private_bytes(
463
+ encoding=serialization.Encoding.PEM,
464
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
465
+ encryption_algorithm=serialization.NoEncryption(),
466
+ )
467
+ public_bytes = private_key.public_key().public_bytes(
468
+ encoding=serialization.Encoding.PEM,
469
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
470
+ )
471
+ priv_path.write_bytes(private_bytes)
472
+ pub_path.write_bytes(public_bytes)
473
+ self.public_key = public_bytes.decode()
474
+ self.save(update_fields=["public_key"])
475
+ elif not self.public_key:
476
+ self.public_key = pub_path.read_text()
477
+ self.save(update_fields=["public_key"])
478
+
479
+ @property
480
+ def is_local(self):
481
+ """Determine if this node represents the current host."""
482
+ return self.mac_address == self.get_current_mac()
483
+
484
+ @classmethod
485
+ def _generate_unique_public_endpoint(
486
+ cls, value: str | None, *, exclude_pk: int | None = None
487
+ ) -> str:
488
+ """Return a unique public endpoint slug for ``value``."""
489
+
490
+ field = cls._meta.get_field("public_endpoint")
491
+ max_length = getattr(field, "max_length", None) or 50
492
+ base_slug = slugify(value or "") or "node"
493
+ if len(base_slug) > max_length:
494
+ base_slug = base_slug[:max_length]
495
+ slug = base_slug
496
+ queryset = cls.objects.all()
497
+ if exclude_pk is not None:
498
+ queryset = queryset.exclude(pk=exclude_pk)
499
+ counter = 2
500
+ while queryset.filter(public_endpoint=slug).exists():
501
+ suffix = f"-{counter}"
502
+ available = max_length - len(suffix)
503
+ if available <= 0:
504
+ slug = suffix[-max_length:]
505
+ else:
506
+ slug = f"{base_slug[:available]}{suffix}"
507
+ counter += 1
508
+ return slug
509
+
510
+ def save(self, *args, **kwargs):
511
+ update_fields = kwargs.get("update_fields")
512
+
513
+ def include_update_field(field: str):
514
+ nonlocal update_fields
515
+ if update_fields is None:
516
+ return
517
+ fields = set(update_fields)
518
+ if field in fields:
519
+ return
520
+ fields.add(field)
521
+ update_fields = tuple(fields)
522
+ kwargs["update_fields"] = update_fields
523
+
524
+ role_name = None
525
+ role = getattr(self, "role", None)
526
+ if role and getattr(role, "name", None):
527
+ role_name = role.name
528
+ elif self.role_id:
529
+ role_name = (
530
+ NodeRole.objects.filter(pk=self.role_id)
531
+ .values_list("name", flat=True)
532
+ .first()
533
+ )
534
+
535
+ role_color = self.ROLE_BADGE_COLORS.get(role_name)
536
+ if role_color and (
537
+ not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
538
+ ):
539
+ self.badge_color = role_color
540
+ include_update_field("badge_color")
541
+
542
+ if self.mac_address:
543
+ self.mac_address = self.mac_address.lower()
544
+ endpoint_value = slugify(self.public_endpoint or "")
545
+ if not endpoint_value:
546
+ endpoint_value = self._generate_unique_public_endpoint(
547
+ self.hostname, exclude_pk=self.pk
548
+ )
549
+ else:
550
+ queryset = (
551
+ self.__class__.objects.exclude(pk=self.pk)
552
+ if self.pk
553
+ else self.__class__.objects.all()
554
+ )
555
+ if queryset.filter(public_endpoint=endpoint_value).exists():
556
+ endpoint_value = self._generate_unique_public_endpoint(
557
+ self.hostname or endpoint_value, exclude_pk=self.pk
558
+ )
559
+ if self.public_endpoint != endpoint_value:
560
+ self.public_endpoint = endpoint_value
561
+ include_update_field("public_endpoint")
562
+ super().save(*args, **kwargs)
563
+ if self.pk:
564
+ self.refresh_features()
565
+
566
+ def has_feature(self, slug: str) -> bool:
567
+ return self.features.filter(slug=slug).exists()
568
+
569
+ @classmethod
570
+ def _has_rpi_camera(cls) -> bool:
571
+ """Return ``True`` when the Raspberry Pi camera stack is available."""
572
+
573
+ device = cls.RPI_CAMERA_DEVICE
574
+ if not device.exists():
575
+ return False
576
+ device_path = str(device)
577
+ try:
578
+ mode = os.stat(device_path).st_mode
579
+ except OSError:
580
+ return False
581
+ if not stat.S_ISCHR(mode):
582
+ return False
583
+ if not os.access(device_path, os.R_OK | os.W_OK):
584
+ return False
585
+ for binary in cls.RPI_CAMERA_BINARIES:
586
+ tool_path = shutil.which(binary)
587
+ if not tool_path:
588
+ return False
589
+ try:
590
+ result = subprocess.run(
591
+ [tool_path, "--help"],
592
+ capture_output=True,
593
+ text=True,
594
+ check=False,
595
+ timeout=5,
596
+ )
597
+ except Exception:
598
+ return False
599
+ if result.returncode != 0:
600
+ return False
601
+ return True
602
+
603
+ @classmethod
604
+ def _hosts_gelectriic_ap(cls) -> bool:
605
+ """Return ``True`` when the node is hosting the gelectriic access point."""
606
+
607
+ nmcli_path = shutil.which("nmcli")
608
+ if not nmcli_path:
609
+ return False
610
+ try:
611
+ result = subprocess.run(
612
+ [
613
+ nmcli_path,
614
+ "-t",
615
+ "-f",
616
+ "NAME,DEVICE,TYPE",
617
+ "connection",
618
+ "show",
619
+ "--active",
620
+ ],
621
+ capture_output=True,
622
+ text=True,
623
+ check=False,
624
+ timeout=cls.NMCLI_TIMEOUT,
625
+ )
626
+ except Exception:
627
+ return False
628
+ if result.returncode != 0:
629
+ return False
630
+ for line in result.stdout.splitlines():
631
+ if not line:
632
+ continue
633
+ parts = line.split(":", 2)
634
+ if not parts:
635
+ continue
636
+ name = parts[0]
637
+ conn_type = ""
638
+ if len(parts) == 3:
639
+ conn_type = parts[2]
640
+ elif len(parts) > 1:
641
+ conn_type = parts[1]
642
+ if name != cls.AP_ROUTER_SSID:
643
+ continue
644
+ conn_type_normalized = conn_type.strip().lower()
645
+ if conn_type_normalized not in {"wifi", "802-11-wireless"}:
646
+ continue
647
+ try:
648
+ mode_result = subprocess.run(
649
+ [
650
+ nmcli_path,
651
+ "-g",
652
+ "802-11-wireless.mode",
653
+ "connection",
654
+ "show",
655
+ name,
656
+ ],
657
+ capture_output=True,
658
+ text=True,
659
+ check=False,
660
+ timeout=cls.NMCLI_TIMEOUT,
661
+ )
662
+ except Exception:
663
+ continue
664
+ if mode_result.returncode != 0:
665
+ continue
666
+ if mode_result.stdout.strip() == "ap":
667
+ return True
668
+ return False
669
+
670
+ @classmethod
671
+ def _find_gway_runner_command(cls) -> str | None:
672
+ command = shutil.which(cls.GWAY_RUNNER_COMMAND)
673
+ if command:
674
+ return command
675
+ for candidate in cls.GWAY_RUNNER_CANDIDATES:
676
+ expanded = os.path.expanduser(candidate)
677
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
678
+ return expanded
679
+ return None
680
+
681
+ @classmethod
682
+ def _has_gway_runner(cls) -> bool:
683
+ return cls._find_gway_runner_command() is not None
684
+
685
+ def refresh_features(self):
686
+ if not self.pk:
687
+ return
688
+ if not self.is_local:
689
+ self.sync_feature_tasks()
690
+ return
691
+ detected_slugs = set()
692
+ base_path = Path(self.base_path or settings.BASE_DIR)
693
+ locks_dir = base_path / "locks"
694
+ for slug, filename in self.FEATURE_LOCK_MAP.items():
695
+ if (locks_dir / filename).exists():
696
+ detected_slugs.add(slug)
697
+ if self._has_rpi_camera():
698
+ detected_slugs.add("rpi-camera")
699
+ if self._has_gway_runner():
700
+ detected_slugs.add("gway-runner")
701
+ if self._hosts_gelectriic_ap():
702
+ detected_slugs.add("ap-router")
703
+ try:
704
+ from core.notifications import supports_gui_toast
705
+ except Exception:
706
+ pass
707
+ else:
708
+ try:
709
+ if supports_gui_toast():
710
+ detected_slugs.add("gui-toast")
711
+ except Exception:
712
+ pass
713
+ current_slugs = set(
714
+ self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
715
+ "slug", flat=True
716
+ )
717
+ )
718
+ add_slugs = detected_slugs - current_slugs
719
+ if add_slugs:
720
+ for feature in NodeFeature.objects.filter(slug__in=add_slugs):
721
+ NodeFeatureAssignment.objects.update_or_create(
722
+ node=self, feature=feature
723
+ )
724
+ remove_slugs = current_slugs - detected_slugs
725
+ if remove_slugs:
726
+ NodeFeatureAssignment.objects.filter(
727
+ node=self, feature__slug__in=remove_slugs
728
+ ).delete()
729
+ self.sync_feature_tasks()
730
+
731
+ def update_manual_features(self, slugs: Iterable[str]):
732
+ desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
733
+ remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
734
+ if remove_slugs:
735
+ NodeFeatureAssignment.objects.filter(
736
+ node=self, feature__slug__in=remove_slugs
737
+ ).delete()
738
+ if desired:
739
+ for feature in NodeFeature.objects.filter(slug__in=desired):
740
+ NodeFeatureAssignment.objects.update_or_create(
741
+ node=self, feature=feature
742
+ )
743
+ self.sync_feature_tasks()
744
+
745
+ def sync_feature_tasks(self):
746
+ clipboard_enabled = self.has_feature("clipboard-poll")
747
+ screenshot_enabled = self.has_feature("screenshot-poll")
748
+ self._sync_clipboard_task(clipboard_enabled)
749
+ self._sync_screenshot_task(screenshot_enabled)
750
+
751
+ def _sync_clipboard_task(self, enabled: bool):
752
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
753
+
754
+ task_name = f"poll_clipboard_node_{self.pk}"
755
+ if enabled:
756
+ schedule, _ = IntervalSchedule.objects.get_or_create(
757
+ every=5, period=IntervalSchedule.SECONDS
758
+ )
759
+ PeriodicTask.objects.update_or_create(
760
+ name=task_name,
761
+ defaults={
762
+ "interval": schedule,
763
+ "task": "nodes.tasks.sample_clipboard",
764
+ },
765
+ )
766
+ else:
767
+ PeriodicTask.objects.filter(name=task_name).delete()
768
+
769
+ def _sync_screenshot_task(self, enabled: bool):
770
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
771
+ import json
772
+
773
+ task_name = f"capture_screenshot_node_{self.pk}"
774
+ if enabled:
775
+ schedule, _ = IntervalSchedule.objects.get_or_create(
776
+ every=1, period=IntervalSchedule.MINUTES
777
+ )
778
+ PeriodicTask.objects.update_or_create(
779
+ name=task_name,
780
+ defaults={
781
+ "interval": schedule,
782
+ "task": "nodes.tasks.capture_node_screenshot",
783
+ "kwargs": json.dumps(
784
+ {
785
+ "url": f"http://localhost:{self.port}",
786
+ "port": self.port,
787
+ "method": "AUTO",
788
+ }
789
+ ),
790
+ },
791
+ )
792
+ else:
793
+ PeriodicTask.objects.filter(name=task_name).delete()
794
+
795
+ def send_mail(
796
+ self,
797
+ subject: str,
798
+ message: str,
799
+ recipient_list: list[str],
800
+ from_email: str | None = None,
801
+ **kwargs,
802
+ ):
803
+ """Send an email using this node's configured outbox if available."""
804
+ outbox = getattr(self, "email_outbox", None)
805
+ logger.info(
806
+ "Node %s queueing email to %s using %s backend",
807
+ self.pk,
808
+ recipient_list,
809
+ "outbox" if outbox else "default",
810
+ )
811
+ return mailer.send(
812
+ subject,
813
+ message,
814
+ recipient_list,
815
+ from_email,
816
+ outbox=outbox,
817
+ **kwargs,
818
+ )
819
+
820
+
821
+ node_information_updated = Signal()
822
+
823
+
824
+ def _format_upgrade_body(version: str, revision: str) -> str:
825
+ version = (version or "").strip()
826
+ revision = (revision or "").strip()
827
+ parts: list[str] = []
828
+ if version:
829
+ normalized = version.lstrip("vV") or version
830
+ base_version = normalized.rstrip("+")
831
+ display_version = normalized
832
+ if (
833
+ base_version
834
+ and revision
835
+ and not PackageRelease.matches_revision(base_version, revision)
836
+ and not normalized.endswith("+")
837
+ ):
838
+ display_version = f"{display_version}+"
839
+ parts.append(f"v{display_version}")
840
+ if revision:
841
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
842
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
843
+ parts.append(f"r{rev_short}")
844
+ return " ".join(parts).strip()
845
+
846
+
847
+ @receiver(node_information_updated)
848
+ def _announce_peer_startup(
849
+ sender,
850
+ *,
851
+ node: "Node",
852
+ previous_version: str = "",
853
+ previous_revision: str = "",
854
+ current_version: str = "",
855
+ current_revision: str = "",
856
+ **_: object,
857
+ ) -> None:
858
+ current_version = (current_version or "").strip()
859
+ current_revision = (current_revision or "").strip()
860
+ previous_version = (previous_version or "").strip()
861
+ previous_revision = (previous_revision or "").strip()
862
+
863
+ local = Node.get_local()
864
+ if local and node.pk == local.pk:
865
+ return
866
+
867
+ body = _format_upgrade_body(current_version, current_revision)
868
+ if not body:
869
+ body = "Online"
870
+
871
+ hostname = (node.hostname or "Node").strip() or "Node"
872
+ subject = f"UP {hostname}"
873
+ notify_async(subject, body)
874
+
875
+
876
+ class NodeFeatureAssignment(Entity):
877
+ """Bridge between :class:`Node` and :class:`NodeFeature`."""
878
+
879
+ node = models.ForeignKey(
880
+ Node, on_delete=models.CASCADE, related_name="feature_assignments"
881
+ )
882
+ feature = models.ForeignKey(
883
+ NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
884
+ )
885
+ created_at = models.DateTimeField(auto_now_add=True)
886
+
887
+ class Meta:
888
+ unique_together = ("node", "feature")
889
+ verbose_name = "Node Feature Assignment"
890
+ verbose_name_plural = "Node Feature Assignments"
891
+
892
+ def __str__(self) -> str: # pragma: no cover - simple representation
893
+ return f"{self.node} -> {self.feature}"
894
+
895
+ def save(self, *args, **kwargs):
896
+ super().save(*args, **kwargs)
897
+ self.node.sync_feature_tasks()
898
+
899
+
900
+ @receiver(post_delete, sender=NodeFeatureAssignment)
901
+ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
902
+ node_id = getattr(instance, "node_id", None)
903
+ if not node_id:
904
+ return
905
+ node = Node.objects.filter(pk=node_id).first()
906
+ if node:
907
+ node.sync_feature_tasks()
908
+
909
+
910
+ class NodeManager(Profile):
911
+ """Credentials for interacting with external DNS providers."""
912
+
913
+ class Provider(models.TextChoices):
914
+ GODADDY = "godaddy", "GoDaddy"
915
+
916
+ profile_fields = (
917
+ "provider",
918
+ "api_key",
919
+ "api_secret",
920
+ "customer_id",
921
+ "default_domain",
922
+ )
923
+
924
+ provider = models.CharField(
925
+ max_length=20,
926
+ choices=Provider.choices,
927
+ default=Provider.GODADDY,
928
+ )
929
+ api_key = SigilShortAutoField(
930
+ max_length=255,
931
+ help_text="API key issued by the DNS provider.",
932
+ )
933
+ api_secret = SigilShortAutoField(
934
+ max_length=255,
935
+ help_text="API secret issued by the DNS provider.",
936
+ )
937
+ customer_id = SigilShortAutoField(
938
+ max_length=100,
939
+ blank=True,
940
+ help_text="Optional GoDaddy customer identifier for the account.",
941
+ )
942
+ default_domain = SigilShortAutoField(
943
+ max_length=253,
944
+ blank=True,
945
+ help_text="Fallback domain when records omit one.",
946
+ )
947
+ use_sandbox = models.BooleanField(
948
+ default=False,
949
+ help_text="Use the GoDaddy OTE (test) environment.",
950
+ )
951
+ is_enabled = models.BooleanField(
952
+ default=True,
953
+ help_text="Disable to prevent deployments with this manager.",
954
+ )
955
+
956
+ class Meta:
957
+ verbose_name = "Node Manager"
958
+ verbose_name_plural = "Node Managers"
959
+
960
+ def __str__(self) -> str:
961
+ owner = self.owner_display()
962
+ provider = self.get_provider_display()
963
+ if owner:
964
+ return f"{provider} ({owner})"
965
+ return provider
966
+
967
+ def clean(self):
968
+ if self.user_id or self.group_id:
969
+ super().clean()
970
+ else:
971
+ super(Profile, self).clean()
972
+
973
+ def get_base_url(self) -> str:
974
+ if self.provider != self.Provider.GODADDY:
975
+ raise ValueError("Unsupported DNS provider")
976
+ if self.use_sandbox:
977
+ return "https://api.ote-godaddy.com"
978
+ return "https://api.godaddy.com"
979
+
980
+ def get_auth_header(self) -> str:
981
+ key = (self.resolve_sigils("api_key") or "").strip()
982
+ secret = (self.resolve_sigils("api_secret") or "").strip()
983
+ if not key or not secret:
984
+ raise ValueError("API credentials are required for DNS deployment")
985
+ return f"sso-key {key}:{secret}"
986
+
987
+ def get_customer_id(self) -> str:
988
+ return (self.resolve_sigils("customer_id") or "").strip()
989
+
990
+ def get_default_domain(self) -> str:
991
+ return (self.resolve_sigils("default_domain") or "").strip()
992
+
993
+ def publish_dns_records(self, records: Iterable["DNSRecord"]):
994
+ from . import dns as dns_utils
995
+
996
+ return dns_utils.deploy_records(self, records)
997
+
998
+
999
+ class DNSRecord(Entity):
1000
+ """Stored DNS configuration ready for deployment."""
1001
+
1002
+ class Type(models.TextChoices):
1003
+ A = "A", "A"
1004
+ AAAA = "AAAA", "AAAA"
1005
+ CNAME = "CNAME", "CNAME"
1006
+ MX = "MX", "MX"
1007
+ NS = "NS", "NS"
1008
+ SRV = "SRV", "SRV"
1009
+ TXT = "TXT", "TXT"
1010
+
1011
+ class Provider(models.TextChoices):
1012
+ GODADDY = "godaddy", "GoDaddy"
1013
+
1014
+ provider = models.CharField(
1015
+ max_length=20,
1016
+ choices=Provider.choices,
1017
+ default=Provider.GODADDY,
1018
+ )
1019
+ node_manager = models.ForeignKey(
1020
+ "NodeManager",
1021
+ on_delete=models.SET_NULL,
1022
+ null=True,
1023
+ blank=True,
1024
+ related_name="dns_records",
1025
+ )
1026
+ domain = SigilShortAutoField(
1027
+ max_length=253,
1028
+ help_text="Base domain such as example.com.",
1029
+ )
1030
+ name = SigilShortAutoField(
1031
+ max_length=253,
1032
+ help_text="Record host. Use @ for the zone apex.",
1033
+ )
1034
+ record_type = models.CharField(
1035
+ max_length=10,
1036
+ choices=Type.choices,
1037
+ default=Type.A,
1038
+ verbose_name="Type",
1039
+ )
1040
+ data = SigilLongAutoField(
1041
+ help_text="Record value such as an IP address or hostname.",
1042
+ )
1043
+ ttl = models.PositiveIntegerField(
1044
+ default=600,
1045
+ help_text="Time to live in seconds.",
1046
+ )
1047
+ priority = models.PositiveIntegerField(
1048
+ null=True,
1049
+ blank=True,
1050
+ help_text="Priority for MX and SRV records.",
1051
+ )
1052
+ port = models.PositiveIntegerField(
1053
+ null=True,
1054
+ blank=True,
1055
+ help_text="Port for SRV records.",
1056
+ )
1057
+ weight = models.PositiveIntegerField(
1058
+ null=True,
1059
+ blank=True,
1060
+ help_text="Weight for SRV records.",
1061
+ )
1062
+ service = SigilShortAutoField(
1063
+ max_length=50,
1064
+ blank=True,
1065
+ help_text="Service label for SRV records (for example _sip).",
1066
+ )
1067
+ protocol = SigilShortAutoField(
1068
+ max_length=10,
1069
+ blank=True,
1070
+ help_text="Protocol label for SRV records (for example _tcp).",
1071
+ )
1072
+ last_synced_at = models.DateTimeField(null=True, blank=True)
1073
+ last_verified_at = models.DateTimeField(null=True, blank=True)
1074
+ last_error = models.TextField(blank=True)
1075
+
1076
+ class Meta:
1077
+ verbose_name = "DNS Record"
1078
+ verbose_name_plural = "DNS Records"
1079
+
1080
+ def __str__(self) -> str:
1081
+ return f"{self.record_type} {self.fqdn()}"
1082
+
1083
+ def get_domain(self, manager: "NodeManager" | None = None) -> str:
1084
+ domain = (self.resolve_sigils("domain") or "").strip()
1085
+ if domain:
1086
+ return domain.rstrip(".")
1087
+ if manager:
1088
+ fallback = manager.get_default_domain()
1089
+ if fallback:
1090
+ return fallback.rstrip(".")
1091
+ return ""
1092
+
1093
+ def get_name(self) -> str:
1094
+ name = (self.resolve_sigils("name") or "").strip()
1095
+ return name or "@"
1096
+
1097
+ def fqdn(self, manager: "NodeManager" | None = None) -> str:
1098
+ domain = self.get_domain(manager)
1099
+ name = self.get_name()
1100
+ if name in {"@", ""}:
1101
+ return domain
1102
+ if name.endswith("."):
1103
+ return name.rstrip(".")
1104
+ if domain:
1105
+ return f"{name}.{domain}".rstrip(".")
1106
+ return name.rstrip(".")
1107
+
1108
+ def to_godaddy_payload(self) -> dict[str, object]:
1109
+ payload: dict[str, object] = {
1110
+ "data": (self.resolve_sigils("data") or "").strip(),
1111
+ "ttl": self.ttl,
1112
+ }
1113
+ if self.priority is not None:
1114
+ payload["priority"] = self.priority
1115
+ if self.port is not None:
1116
+ payload["port"] = self.port
1117
+ if self.weight is not None:
1118
+ payload["weight"] = self.weight
1119
+ service = (self.resolve_sigils("service") or "").strip()
1120
+ if service:
1121
+ payload["service"] = service
1122
+ protocol = (self.resolve_sigils("protocol") or "").strip()
1123
+ if protocol:
1124
+ payload["protocol"] = protocol
1125
+ return payload
1126
+
1127
+ def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
1128
+ if timestamp is None:
1129
+ timestamp = timezone.now()
1130
+ update_fields = ["last_synced_at", "last_error"]
1131
+ self.last_synced_at = timestamp
1132
+ self.last_error = ""
1133
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1134
+ self.node_manager = manager
1135
+ update_fields.append("node_manager")
1136
+ self.save(update_fields=update_fields)
1137
+
1138
+ def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
1139
+ update_fields = ["last_error"]
1140
+ self.last_error = message
1141
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1142
+ self.node_manager = manager
1143
+ update_fields.append("node_manager")
1144
+ self.save(update_fields=update_fields)
1145
+
1146
+
1147
+ class EmailOutbox(Profile):
1148
+ """SMTP credentials for sending mail."""
1149
+
1150
+ profile_fields = (
1151
+ "host",
1152
+ "port",
1153
+ "username",
1154
+ "password",
1155
+ "use_tls",
1156
+ "use_ssl",
1157
+ "from_email",
1158
+ )
1159
+
1160
+ node = models.OneToOneField(
1161
+ Node,
1162
+ on_delete=models.CASCADE,
1163
+ related_name="email_outbox",
1164
+ null=True,
1165
+ blank=True,
1166
+ )
1167
+ host = SigilShortAutoField(
1168
+ max_length=100,
1169
+ help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
1170
+ )
1171
+ port = models.PositiveIntegerField(
1172
+ default=587,
1173
+ help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
1174
+ )
1175
+ username = SigilShortAutoField(
1176
+ max_length=100,
1177
+ blank=True,
1178
+ help_text="Full email address for Gmail or GoDaddy",
1179
+ )
1180
+ password = SigilShortAutoField(
1181
+ max_length=100,
1182
+ blank=True,
1183
+ help_text="Email account password or app password",
1184
+ )
1185
+ use_tls = models.BooleanField(
1186
+ default=True,
1187
+ help_text="Check for Gmail or GoDaddy on port 587",
1188
+ )
1189
+ use_ssl = models.BooleanField(
1190
+ default=False,
1191
+ help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
1192
+ )
1193
+ from_email = SigilShortAutoField(
1194
+ blank=True,
1195
+ verbose_name="From Email",
1196
+ max_length=254,
1197
+ help_text="Default From address; usually the same as username",
1198
+ )
1199
+ is_enabled = models.BooleanField(
1200
+ default=True,
1201
+ help_text="Disable to remove this outbox from automatic selection.",
1202
+ )
1203
+
1204
+ class Meta:
1205
+ verbose_name = "Email Outbox"
1206
+ verbose_name_plural = "Email Outboxes"
1207
+
1208
+ def __str__(self) -> str:
1209
+ address = (self.from_email or "").strip()
1210
+ if address:
1211
+ return address
1212
+
1213
+ username = (self.username or "").strip()
1214
+ host = (self.host or "").strip()
1215
+ if username:
1216
+ local, sep, domain = username.partition("@")
1217
+ if sep and domain:
1218
+ return username
1219
+ if host:
1220
+ sanitized = username.rstrip("@")
1221
+ if sanitized:
1222
+ return f"{sanitized}@{host}"
1223
+ return host
1224
+ return username
1225
+ if host:
1226
+ return host
1227
+
1228
+ owner = self.owner_display()
1229
+ if owner:
1230
+ return owner
1231
+
1232
+ return super().__str__()
1233
+
1234
+ def clean(self):
1235
+ if self.user_id or self.group_id:
1236
+ super().clean()
1237
+ else:
1238
+ super(Profile, self).clean()
1239
+
1240
+ def get_connection(self):
1241
+ return get_connection(
1242
+ "django.core.mail.backends.smtp.EmailBackend",
1243
+ host=self.host,
1244
+ port=self.port,
1245
+ username=self.username or None,
1246
+ password=self.password or None,
1247
+ use_tls=self.use_tls,
1248
+ use_ssl=self.use_ssl,
1249
+ )
1250
+
1251
+ def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
1252
+ from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
1253
+ logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
1254
+ return mailer.send(
1255
+ subject,
1256
+ message,
1257
+ recipient_list,
1258
+ from_email,
1259
+ outbox=self,
1260
+ **kwargs,
1261
+ )
1262
+
1263
+ def owner_display(self):
1264
+ owner = super().owner_display()
1265
+ if owner:
1266
+ return owner
1267
+ return str(self.node) if self.node_id else ""
1268
+
1269
+
1270
+ class NetMessage(Entity):
1271
+ """Message propagated across nodes."""
1272
+
1273
+ uuid = models.UUIDField(
1274
+ default=uuid.uuid4,
1275
+ unique=True,
1276
+ editable=False,
1277
+ verbose_name="UUID",
1278
+ )
1279
+ node_origin = models.ForeignKey(
1280
+ "Node",
1281
+ on_delete=models.SET_NULL,
1282
+ null=True,
1283
+ blank=True,
1284
+ related_name="originated_net_messages",
1285
+ )
1286
+ subject = models.CharField(max_length=64, blank=True)
1287
+ body = models.CharField(max_length=256, blank=True)
1288
+ attachments = models.JSONField(blank=True, null=True)
1289
+ filter_node = models.ForeignKey(
1290
+ "Node",
1291
+ on_delete=models.SET_NULL,
1292
+ null=True,
1293
+ blank=True,
1294
+ related_name="filtered_net_messages",
1295
+ verbose_name="Node",
1296
+ )
1297
+ filter_node_feature = models.ForeignKey(
1298
+ "NodeFeature",
1299
+ on_delete=models.SET_NULL,
1300
+ null=True,
1301
+ blank=True,
1302
+ verbose_name="Node feature",
1303
+ )
1304
+ filter_node_role = models.ForeignKey(
1305
+ NodeRole,
1306
+ on_delete=models.SET_NULL,
1307
+ null=True,
1308
+ blank=True,
1309
+ related_name="filtered_net_messages",
1310
+ verbose_name="Node role",
1311
+ )
1312
+ filter_current_relation = models.CharField(
1313
+ max_length=10,
1314
+ blank=True,
1315
+ choices=Node.Relation.choices,
1316
+ verbose_name="Current relation",
1317
+ )
1318
+ filter_installed_version = models.CharField(
1319
+ max_length=20,
1320
+ blank=True,
1321
+ verbose_name="Installed version",
1322
+ )
1323
+ filter_installed_revision = models.CharField(
1324
+ max_length=40,
1325
+ blank=True,
1326
+ verbose_name="Installed revision",
1327
+ )
1328
+ reach = models.ForeignKey(
1329
+ NodeRole,
1330
+ on_delete=models.SET_NULL,
1331
+ null=True,
1332
+ blank=True,
1333
+ )
1334
+ target_limit = models.PositiveSmallIntegerField(
1335
+ default=6,
1336
+ blank=True,
1337
+ null=True,
1338
+ help_text="Maximum number of peers to contact when propagating.",
1339
+ )
1340
+ propagated_to = models.ManyToManyField(
1341
+ Node, blank=True, related_name="received_net_messages"
1342
+ )
1343
+ created = models.DateTimeField(auto_now_add=True)
1344
+ complete = models.BooleanField(default=False, editable=False)
1345
+
1346
+ class Meta:
1347
+ ordering = ["-created"]
1348
+ verbose_name = "Net Message"
1349
+ verbose_name_plural = "Net Messages"
1350
+
1351
+ @classmethod
1352
+ def broadcast(
1353
+ cls,
1354
+ subject: str,
1355
+ body: str,
1356
+ reach: NodeRole | str | None = None,
1357
+ seen: list[str] | None = None,
1358
+ attachments: list[dict[str, object]] | None = None,
1359
+ ):
1360
+ role = None
1361
+ if reach:
1362
+ if isinstance(reach, NodeRole):
1363
+ role = reach
1364
+ else:
1365
+ role = NodeRole.objects.filter(name=reach).first()
1366
+ else:
1367
+ role = NodeRole.objects.filter(name="Terminal").first()
1368
+ origin = Node.get_local()
1369
+ normalized_attachments = cls.normalize_attachments(attachments)
1370
+ msg = cls.objects.create(
1371
+ subject=subject[:64],
1372
+ body=body[:256],
1373
+ reach=role,
1374
+ node_origin=origin,
1375
+ attachments=normalized_attachments or None,
1376
+ )
1377
+ if normalized_attachments:
1378
+ msg.apply_attachments(normalized_attachments)
1379
+ msg.propagate(seen=seen or [])
1380
+ return msg
1381
+
1382
+ @staticmethod
1383
+ def normalize_attachments(
1384
+ attachments: object,
1385
+ ) -> list[dict[str, object]]:
1386
+ if not attachments or not isinstance(attachments, list):
1387
+ return []
1388
+ normalized: list[dict[str, object]] = []
1389
+ for item in attachments:
1390
+ if not isinstance(item, dict):
1391
+ continue
1392
+ model_label = item.get("model")
1393
+ fields = item.get("fields")
1394
+ if not isinstance(model_label, str) or not isinstance(fields, dict):
1395
+ continue
1396
+ normalized_item: dict[str, object] = {
1397
+ "model": model_label,
1398
+ "fields": deepcopy(fields),
1399
+ }
1400
+ if "pk" in item:
1401
+ normalized_item["pk"] = item["pk"]
1402
+ normalized.append(normalized_item)
1403
+ return normalized
1404
+
1405
+ def apply_attachments(
1406
+ self, attachments: list[dict[str, object]] | None = None
1407
+ ) -> None:
1408
+ payload = attachments if attachments is not None else self.attachments or []
1409
+ if not payload:
1410
+ return
1411
+ try:
1412
+ objects = list(
1413
+ serializers.deserialize(
1414
+ "python", deepcopy(payload), ignorenonexistent=True
1415
+ )
1416
+ )
1417
+ except DeserializationError:
1418
+ logger.exception("Failed to deserialize attachments for NetMessage %s", self.pk)
1419
+ return
1420
+ for obj in objects:
1421
+ try:
1422
+ obj.save()
1423
+ except Exception:
1424
+ logger.exception(
1425
+ "Failed to save attachment %s for NetMessage %s",
1426
+ getattr(obj, "object", obj),
1427
+ self.pk,
1428
+ )
1429
+
1430
+ def propagate(self, seen: list[str] | None = None):
1431
+ from core.notifications import notify
1432
+ import random
1433
+ import requests
1434
+
1435
+ displayed = notify(self.subject, self.body)
1436
+ local = Node.get_local()
1437
+ if displayed:
1438
+ cutoff = timezone.now() - timedelta(days=7)
1439
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
1440
+ if local:
1441
+ prune_qs = prune_qs.filter(
1442
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
1443
+ )
1444
+ else:
1445
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
1446
+ if self.pk:
1447
+ prune_qs = prune_qs.exclude(pk=self.pk)
1448
+ prune_qs.delete()
1449
+ if local and not self.node_origin_id:
1450
+ self.node_origin = local
1451
+ self.save(update_fields=["node_origin"])
1452
+ origin_uuid = None
1453
+ if self.node_origin_id:
1454
+ origin_uuid = str(self.node_origin.uuid)
1455
+ elif local:
1456
+ origin_uuid = str(local.uuid)
1457
+ private_key = None
1458
+ seen = list(seen or [])
1459
+ local_id = None
1460
+ if local:
1461
+ local_id = str(local.uuid)
1462
+ if local_id not in seen:
1463
+ seen.append(local_id)
1464
+ priv_path = (
1465
+ Path(local.base_path or settings.BASE_DIR)
1466
+ / "security"
1467
+ / f"{local.public_endpoint}"
1468
+ )
1469
+ try:
1470
+ private_key = serialization.load_pem_private_key(
1471
+ priv_path.read_bytes(), password=None
1472
+ )
1473
+ except Exception:
1474
+ private_key = None
1475
+ for node_id in seen:
1476
+ node = Node.objects.filter(uuid=node_id).first()
1477
+ if node and (not local or node.pk != local.pk):
1478
+ self.propagated_to.add(node)
1479
+
1480
+ filtered_nodes = Node.objects.all()
1481
+ if self.filter_node_id:
1482
+ filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
1483
+ if self.filter_node_feature_id:
1484
+ filtered_nodes = filtered_nodes.filter(
1485
+ features__pk=self.filter_node_feature_id
1486
+ )
1487
+ if self.filter_node_role_id:
1488
+ filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
1489
+ if self.filter_current_relation:
1490
+ filtered_nodes = filtered_nodes.filter(
1491
+ current_relation=self.filter_current_relation
1492
+ )
1493
+ if self.filter_installed_version:
1494
+ filtered_nodes = filtered_nodes.filter(
1495
+ installed_version=self.filter_installed_version
1496
+ )
1497
+ if self.filter_installed_revision:
1498
+ filtered_nodes = filtered_nodes.filter(
1499
+ installed_revision=self.filter_installed_revision
1500
+ )
1501
+
1502
+ filtered_nodes = filtered_nodes.distinct()
1503
+
1504
+ if local:
1505
+ filtered_nodes = filtered_nodes.exclude(pk=local.pk)
1506
+ total_known = filtered_nodes.count()
1507
+
1508
+ remaining = list(
1509
+ filtered_nodes.exclude(
1510
+ pk__in=self.propagated_to.values_list("pk", flat=True)
1511
+ )
1512
+ )
1513
+ if not remaining:
1514
+ self.complete = True
1515
+ self.save(update_fields=["complete"])
1516
+ return
1517
+
1518
+ limit = self.target_limit or 6
1519
+ target_limit = min(limit, len(remaining))
1520
+
1521
+ reach_source = self.filter_node_role or self.reach
1522
+ reach_name = reach_source.name if reach_source else None
1523
+ role_map = {
1524
+ "Terminal": ["Terminal"],
1525
+ "Control": ["Control", "Terminal"],
1526
+ "Satellite": ["Satellite", "Control", "Terminal"],
1527
+ "Constellation": [
1528
+ "Constellation",
1529
+ "Satellite",
1530
+ "Control",
1531
+ "Terminal",
1532
+ ],
1533
+ }
1534
+ selected: list[Node] = []
1535
+ if self.filter_node_id:
1536
+ target = next((n for n in remaining if n.pk == self.filter_node_id), None)
1537
+ if target:
1538
+ selected = [target]
1539
+ else:
1540
+ self.complete = True
1541
+ self.save(update_fields=["complete"])
1542
+ return
1543
+ else:
1544
+ if self.filter_node_role_id:
1545
+ role_order = [reach_name]
1546
+ else:
1547
+ role_order = role_map.get(reach_name, [None])
1548
+ for role_name in role_order:
1549
+ if role_name is None:
1550
+ role_nodes = remaining[:]
1551
+ else:
1552
+ role_nodes = [
1553
+ n for n in remaining if n.role and n.role.name == role_name
1554
+ ]
1555
+ random.shuffle(role_nodes)
1556
+ for n in role_nodes:
1557
+ selected.append(n)
1558
+ remaining.remove(n)
1559
+ if len(selected) >= target_limit:
1560
+ break
1561
+ if len(selected) >= target_limit:
1562
+ break
1563
+
1564
+ if not selected:
1565
+ self.complete = True
1566
+ self.save(update_fields=["complete"])
1567
+ return
1568
+
1569
+ seen_list = seen.copy()
1570
+ selected_ids = [str(n.uuid) for n in selected]
1571
+ payload_seen = seen_list + selected_ids
1572
+ for node in selected:
1573
+ payload = {
1574
+ "uuid": str(self.uuid),
1575
+ "subject": self.subject,
1576
+ "body": self.body,
1577
+ "seen": payload_seen,
1578
+ "reach": reach_name,
1579
+ "sender": local_id,
1580
+ "origin": origin_uuid,
1581
+ }
1582
+ if self.attachments:
1583
+ payload["attachments"] = self.attachments
1584
+ if self.filter_node:
1585
+ payload["filter_node"] = str(self.filter_node.uuid)
1586
+ if self.filter_node_feature:
1587
+ payload["filter_node_feature"] = self.filter_node_feature.slug
1588
+ if self.filter_node_role:
1589
+ payload["filter_node_role"] = self.filter_node_role.name
1590
+ if self.filter_current_relation:
1591
+ payload["filter_current_relation"] = self.filter_current_relation
1592
+ if self.filter_installed_version:
1593
+ payload["filter_installed_version"] = self.filter_installed_version
1594
+ if self.filter_installed_revision:
1595
+ payload["filter_installed_revision"] = self.filter_installed_revision
1596
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1597
+ headers = {"Content-Type": "application/json"}
1598
+ if private_key:
1599
+ try:
1600
+ signature = private_key.sign(
1601
+ payload_json.encode(),
1602
+ padding.PKCS1v15(),
1603
+ hashes.SHA256(),
1604
+ )
1605
+ headers["X-Signature"] = base64.b64encode(signature).decode()
1606
+ except Exception:
1607
+ pass
1608
+ try:
1609
+ requests.post(
1610
+ f"http://{node.address}:{node.port}/nodes/net-message/",
1611
+ data=payload_json,
1612
+ headers=headers,
1613
+ timeout=1,
1614
+ )
1615
+ except Exception:
1616
+ pass
1617
+ self.propagated_to.add(node)
1618
+
1619
+ if total_known and self.propagated_to.count() >= total_known:
1620
+ self.complete = True
1621
+ self.save(update_fields=["complete"] if self.complete else [])
1622
+
1623
+
1624
+ class ContentSample(Entity):
1625
+ """Collected content such as text snippets or screenshots."""
1626
+
1627
+ TEXT = "TEXT"
1628
+ IMAGE = "IMAGE"
1629
+ KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
1630
+
1631
+ name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
1632
+ kind = models.CharField(max_length=10, choices=KIND_CHOICES)
1633
+ content = models.TextField(blank=True)
1634
+ path = models.CharField(max_length=255, blank=True)
1635
+ method = models.CharField(max_length=10, default="", blank=True)
1636
+ hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
1637
+ transaction_uuid = models.UUIDField(
1638
+ default=uuid.uuid4,
1639
+ editable=True,
1640
+ db_index=True,
1641
+ verbose_name="transaction UUID",
1642
+ )
1643
+ node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
1644
+ user = models.ForeignKey(
1645
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
1646
+ )
1647
+ created_at = models.DateTimeField(auto_now_add=True)
1648
+
1649
+ class Meta:
1650
+ ordering = ["-created_at"]
1651
+ verbose_name = "Content Sample"
1652
+ verbose_name_plural = "Content Samples"
1653
+
1654
+ def save(self, *args, **kwargs):
1655
+ if self.pk:
1656
+ original = type(self).all_objects.get(pk=self.pk)
1657
+ if original.transaction_uuid != self.transaction_uuid:
1658
+ raise ValidationError(
1659
+ {"transaction_uuid": "Cannot modify transaction UUID"}
1660
+ )
1661
+ if self.node_id is None:
1662
+ self.node = Node.get_local()
1663
+ super().save(*args, **kwargs)
1664
+
1665
+ def __str__(self) -> str: # pragma: no cover - simple representation
1666
+ return str(self.name)
1667
+
1668
+
1669
+ class ContentClassifier(Entity):
1670
+ """Configured callable that classifies :class:`ContentSample` objects."""
1671
+
1672
+ slug = models.SlugField(max_length=100, unique=True)
1673
+ label = models.CharField(max_length=150)
1674
+ kind = models.CharField(max_length=10, choices=ContentSample.KIND_CHOICES)
1675
+ entrypoint = models.CharField(max_length=255, help_text="Dotted path to classifier callable")
1676
+ run_by_default = models.BooleanField(default=True)
1677
+ active = models.BooleanField(default=True)
1678
+
1679
+ class Meta:
1680
+ ordering = ["label"]
1681
+ verbose_name = "Content Classifier"
1682
+ verbose_name_plural = "Content Classifiers"
1683
+
1684
+ def __str__(self) -> str: # pragma: no cover - simple representation
1685
+ return self.label
1686
+
1687
+
1688
+ class ContentTag(Entity):
1689
+ """Tag that can be attached to classified content samples."""
1690
+
1691
+ slug = models.SlugField(max_length=100, unique=True)
1692
+ label = models.CharField(max_length=150)
1693
+
1694
+ class Meta:
1695
+ ordering = ["label"]
1696
+ verbose_name = "Content Tag"
1697
+ verbose_name_plural = "Content Tags"
1698
+
1699
+ def __str__(self) -> str: # pragma: no cover - simple representation
1700
+ return self.label
1701
+
1702
+
1703
+ class ContentClassification(Entity):
1704
+ """Link between a sample, classifier, and assigned tag."""
1705
+
1706
+ sample = models.ForeignKey(
1707
+ ContentSample, on_delete=models.CASCADE, related_name="classifications"
1708
+ )
1709
+ classifier = models.ForeignKey(
1710
+ ContentClassifier, on_delete=models.CASCADE, related_name="classifications"
1711
+ )
1712
+ tag = models.ForeignKey(
1713
+ ContentTag, on_delete=models.CASCADE, related_name="classifications"
1714
+ )
1715
+ confidence = models.FloatField(null=True, blank=True)
1716
+ metadata = models.JSONField(blank=True, null=True)
1717
+ created_at = models.DateTimeField(auto_now_add=True)
1718
+
1719
+ class Meta:
1720
+ unique_together = ("sample", "classifier", "tag")
1721
+ ordering = ["-created_at"]
1722
+ verbose_name = "Content Classification"
1723
+ verbose_name_plural = "Content Classifications"
1724
+
1725
+ def __str__(self) -> str: # pragma: no cover - simple representation
1726
+ return f"{self.sample} → {self.tag}"
1727
+
1728
+
1729
+ UserModel = get_user_model()
1730
+
1731
+
1732
+ class User(UserModel):
1733
+ class Meta:
1734
+ proxy = True
1735
+ app_label = "nodes"
1736
+ verbose_name = UserModel._meta.verbose_name
1737
+ verbose_name_plural = UserModel._meta.verbose_name_plural