arthexis 0.1.21__py3-none-any.whl → 0.1.23__py3-none-any.whl

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

Potentially problematic release.


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

nodes/admin.py CHANGED
@@ -18,8 +18,10 @@ from django.utils.dateparse import parse_datetime
18
18
  from django.utils.html import format_html, format_html_join
19
19
  from django.utils.translation import gettext_lazy as _
20
20
  from pathlib import Path
21
- from urllib.parse import urlsplit, urlunsplit
21
+ from types import SimpleNamespace
22
+ from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
22
23
  import base64
24
+ import ipaddress
23
25
  import json
24
26
  import subprocess
25
27
  import uuid
@@ -85,7 +87,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
85
87
 
86
88
  class DeployDNSRecordsForm(forms.Form):
87
89
  manager = forms.ModelChoiceField(
88
- label="Node Manager",
90
+ label="Node Profile",
89
91
  queryset=NodeManager.objects.none(),
90
92
  help_text="Credentials used to authenticate with the DNS provider.",
91
93
  )
@@ -234,6 +236,7 @@ class NodeAdmin(EntityModelAdmin):
234
236
  "role",
235
237
  "relation",
236
238
  "last_seen",
239
+ "visit_link",
237
240
  "proxy_link",
238
241
  )
239
242
  search_fields = ("hostname", "address", "mac_address")
@@ -284,7 +287,6 @@ class NodeAdmin(EntityModelAdmin):
284
287
  "register_visitor",
285
288
  "run_task",
286
289
  "take_screenshots",
287
- "fetch_rfids_from_selected",
288
290
  "import_rfids_from_selected",
289
291
  "export_rfids_to_selected",
290
292
  ]
@@ -304,6 +306,49 @@ class NodeAdmin(EntityModelAdmin):
304
306
  return ""
305
307
  return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
306
308
 
309
+ @admin.display(description=_("Visit"))
310
+ def visit_link(self, obj):
311
+ if not obj:
312
+ return ""
313
+ if obj.is_local:
314
+ try:
315
+ url = reverse("admin:index")
316
+ except NoReverseMatch:
317
+ return ""
318
+ return format_html(
319
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
320
+ url,
321
+ _("Visit"),
322
+ )
323
+
324
+ host_values: list[str] = []
325
+ for attr in ("hostname", "address", "public_endpoint"):
326
+ value = getattr(obj, attr, "") or ""
327
+ cleaned = value.strip()
328
+ if cleaned and cleaned not in host_values:
329
+ host_values.append(cleaned)
330
+
331
+ remote_url = ""
332
+ for host in host_values:
333
+ temp_node = SimpleNamespace(
334
+ public_endpoint=host,
335
+ address="",
336
+ hostname="",
337
+ port=obj.port,
338
+ )
339
+ remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
340
+ if remote_url:
341
+ break
342
+
343
+ if not remote_url:
344
+ return ""
345
+
346
+ return format_html(
347
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
348
+ remote_url,
349
+ _("Visit"),
350
+ )
351
+
307
352
  def get_urls(self):
308
353
  urls = super().get_urls()
309
354
  custom = [
@@ -395,6 +440,12 @@ class NodeAdmin(EntityModelAdmin):
395
440
  },
396
441
  "target": reverse("admin:index"),
397
442
  }
443
+ mac_address = str(local_node.mac_address or "").strip()
444
+ if mac_address:
445
+ payload["requester_mac"] = mac_address
446
+ public_key = local_node.public_key
447
+ if public_key:
448
+ payload["requester_public_key"] = public_key
398
449
  return payload
399
450
 
400
451
  def _start_proxy_session(self, request, node):
@@ -429,29 +480,64 @@ class NodeAdmin(EntityModelAdmin):
429
480
  }
430
481
 
431
482
  last_error = ""
483
+ redirect_codes = {301, 302, 303, 307, 308}
484
+
432
485
  for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
433
- try:
434
- response = requests.post(url, data=body, headers=headers, timeout=5)
435
- except RequestException as exc:
436
- last_error = str(exc)
437
- continue
438
- if not response.ok:
439
- last_error = f"{response.status_code} {response.text}"
440
- continue
441
- try:
442
- data = response.json()
443
- except ValueError:
444
- last_error = "Invalid JSON response"
445
- continue
446
- login_url = data.get("login_url")
447
- if not login_url:
448
- last_error = "login_url missing"
449
- continue
450
- return {
451
- "ok": True,
452
- "login_url": login_url,
453
- "expires": data.get("expires"),
454
- }
486
+ candidate_url = url
487
+ redirects_followed = 0
488
+ success = False
489
+
490
+ while True:
491
+ try:
492
+ response = requests.post(
493
+ candidate_url,
494
+ data=body,
495
+ headers=headers,
496
+ timeout=5,
497
+ allow_redirects=False,
498
+ )
499
+ except RequestException as exc:
500
+ last_error = str(exc)
501
+ break
502
+
503
+ if response.status_code in redirect_codes:
504
+ location = response.headers.get("Location")
505
+ if not location:
506
+ last_error = f"{response.status_code} redirect missing Location header"
507
+ break
508
+
509
+ redirects_followed += 1
510
+ if redirects_followed > 3:
511
+ last_error = "Too many redirects"
512
+ break
513
+
514
+ candidate_url = urljoin(candidate_url, location)
515
+ continue
516
+
517
+ if not response.ok:
518
+ last_error = f"{response.status_code} {response.text}"
519
+ break
520
+
521
+ try:
522
+ data = response.json()
523
+ except ValueError:
524
+ last_error = "Invalid JSON response"
525
+ break
526
+
527
+ login_url = data.get("login_url")
528
+ if not login_url:
529
+ last_error = "login_url missing"
530
+ break
531
+
532
+ success = True
533
+ break
534
+
535
+ if success:
536
+ return {
537
+ "ok": True,
538
+ "login_url": login_url,
539
+ "expires": data.get("expires"),
540
+ }
455
541
 
456
542
  return {
457
543
  "ok": False,
@@ -598,6 +684,17 @@ class NodeAdmin(EntityModelAdmin):
598
684
  setattr(node, field, value)
599
685
  changed.append(field)
600
686
 
687
+ role_value = payload.get("role") or payload.get("role_name")
688
+ if role_value is not None:
689
+ role_name = str(role_value).strip()
690
+ if role_name:
691
+ desired_role = NodeRole.objects.filter(name=role_name).first()
692
+ else:
693
+ desired_role = None
694
+ if desired_role and node.role_id != desired_role.id:
695
+ node.role = desired_role
696
+ changed.append("role")
697
+
601
698
  node.last_seen = timezone.now()
602
699
  if "last_seen" not in changed:
603
700
  changed.append("last_seen")
@@ -677,40 +774,96 @@ class NodeAdmin(EntityModelAdmin):
677
774
  return {"ok": False, "message": last_error or "Unable to reach remote node."}
678
775
 
679
776
  def _iter_remote_urls(self, node, path):
680
- host_candidates = []
777
+ host_candidates: list[str] = []
681
778
  for attr in ("public_endpoint", "address", "hostname"):
682
779
  value = getattr(node, attr, "") or ""
683
- value = value.strip()
684
- if value and value not in host_candidates:
685
- host_candidates.append(value)
780
+ cleaned = value.strip()
781
+ if cleaned and cleaned not in host_candidates:
782
+ host_candidates.append(cleaned)
686
783
 
687
- port = node.port or 8000
784
+ default_port = node.port or 8000
688
785
  normalized_path = path if path.startswith("/") else f"/{path}"
689
- seen = set()
786
+ seen: set[str] = set()
690
787
 
691
788
  for host in host_candidates:
789
+ base_path = ""
692
790
  formatted_host = host
693
- if ":" in host and not host.startswith("["):
694
- formatted_host = f"[{host}]"
695
-
696
- candidates = []
697
- if port == 80:
698
- candidates = [
699
- f"http://{formatted_host}{normalized_path}",
700
- f"https://{formatted_host}{normalized_path}",
701
- ]
702
- elif port == 443:
703
- candidates = [
704
- f"https://{formatted_host}{normalized_path}",
705
- f"http://{formatted_host}:{port}{normalized_path}",
706
- ]
707
- else:
708
- candidates = [
709
- f"http://{formatted_host}:{port}{normalized_path}",
710
- f"https://{formatted_host}:{port}{normalized_path}",
711
- ]
791
+ port_override: int | None = None
792
+
793
+ if "://" in host:
794
+ parsed = urlparse(host)
795
+ netloc = parsed.netloc or parsed.path
796
+ base_path = (parsed.path or "").rstrip("/")
797
+ combined_path = (
798
+ f"{base_path}{normalized_path}" if base_path else normalized_path
799
+ )
800
+ primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
801
+ if primary not in seen:
802
+ seen.add(primary)
803
+ yield primary
804
+ if parsed.scheme == "https":
805
+ fallback = urlunsplit(("http", netloc, combined_path, "", ""))
806
+ if fallback not in seen:
807
+ seen.add(fallback)
808
+ yield fallback
809
+ elif parsed.scheme == "http":
810
+ alternate = urlunsplit(("https", netloc, combined_path, "", ""))
811
+ if alternate not in seen:
812
+ seen.add(alternate)
813
+ yield alternate
814
+ continue
712
815
 
713
- for candidate in candidates:
816
+ if host.startswith("[") and "]" in host:
817
+ end = host.index("]")
818
+ core_host = host[1:end]
819
+ remainder = host[end + 1 :]
820
+ if remainder.startswith(":"):
821
+ remainder = remainder[1:]
822
+ port_part, sep, path_tail = remainder.partition("/")
823
+ if port_part:
824
+ try:
825
+ port_override = int(port_part)
826
+ except ValueError:
827
+ port_override = None
828
+ if sep:
829
+ base_path = f"/{path_tail}".rstrip("/")
830
+ elif "/" in remainder:
831
+ _, _, path_tail = remainder.partition("/")
832
+ base_path = f"/{path_tail}".rstrip("/")
833
+ formatted_host = f"[{core_host}]"
834
+ else:
835
+ if "/" in host:
836
+ host_only, _, path_tail = host.partition("/")
837
+ formatted_host = host_only or host
838
+ base_path = f"/{path_tail}".rstrip("/")
839
+ try:
840
+ ip_obj = ipaddress.ip_address(formatted_host)
841
+ except ValueError:
842
+ parts = formatted_host.rsplit(":", 1)
843
+ if len(parts) == 2 and parts[1].isdigit():
844
+ formatted_host = parts[0]
845
+ port_override = int(parts[1])
846
+ try:
847
+ ip_obj = ipaddress.ip_address(formatted_host)
848
+ except ValueError:
849
+ ip_obj = None
850
+ else:
851
+ if ip_obj.version == 6 and not formatted_host.startswith("["):
852
+ formatted_host = f"[{formatted_host}]"
853
+
854
+ effective_port = port_override if port_override is not None else default_port
855
+ combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
856
+
857
+ for scheme, scheme_default_port in (("https", 443), ("http", 80)):
858
+ base = f"{scheme}://{formatted_host}"
859
+ if effective_port and (
860
+ port_override is not None or effective_port != scheme_default_port
861
+ ):
862
+ explicit = f"{base}:{effective_port}{combined_path}"
863
+ if explicit not in seen:
864
+ seen.add(explicit)
865
+ yield explicit
866
+ candidate = f"{base}{combined_path}"
714
867
  if candidate not in seen:
715
868
  seen.add(candidate)
716
869
  yield candidate
@@ -999,17 +1152,19 @@ class NodeAdmin(EntityModelAdmin):
999
1152
  result["status"] = self._status_from_result(result)
1000
1153
  return result
1001
1154
 
1002
- def _run_rfid_fetch(self, request, queryset, *, operation):
1155
+ def _run_rfid_import(self, request, queryset):
1003
1156
  nodes = list(queryset)
1004
1157
  local_node, private_key, error = self._load_local_node_credentials()
1005
1158
  if error:
1006
1159
  results = [self._skip_result(node, error) for node in nodes]
1007
- return self._render_rfid_sync(request, operation, results, setup_error=error)
1160
+ return self._render_rfid_sync(
1161
+ request, "import", results, setup_error=error
1162
+ )
1008
1163
 
1009
1164
  if not nodes:
1010
1165
  return self._render_rfid_sync(
1011
1166
  request,
1012
- operation,
1167
+ "import",
1013
1168
  [],
1014
1169
  setup_error=_("No nodes selected."),
1015
1170
  )
@@ -1032,15 +1187,11 @@ class NodeAdmin(EntityModelAdmin):
1032
1187
  continue
1033
1188
  results.append(self._process_import_from_node(node, payload, headers))
1034
1189
 
1035
- return self._render_rfid_sync(request, operation, results)
1036
-
1037
- @admin.action(description=_("Fetch RFIDs from selected"))
1038
- def fetch_rfids_from_selected(self, request, queryset):
1039
- return self._run_rfid_fetch(request, queryset, operation="fetch")
1190
+ return self._render_rfid_sync(request, "import", results)
1040
1191
 
1041
1192
  @admin.action(description=_("Import RFIDs from selected"))
1042
1193
  def import_rfids_from_selected(self, request, queryset):
1043
- return self._run_rfid_fetch(request, queryset, operation="import")
1194
+ return self._run_rfid_import(request, queryset)
1044
1195
 
1045
1196
  @admin.action(description=_("Export RFIDs to selected"))
1046
1197
  def export_rfids_to_selected(self, request, queryset):
nodes/apps.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  import socket
4
+ import sys
4
5
  import threading
5
6
  import time
6
7
  from pathlib import Path
@@ -66,6 +67,10 @@ def _startup_notification() -> None:
66
67
  def _trigger_startup_notification(**_: object) -> None:
67
68
  """Attempt to send the startup notification in the background."""
68
69
 
70
+ if _is_running_migration_command():
71
+ logger.debug("Startup notification skipped: running migration command")
72
+ return
73
+
69
74
  try:
70
75
  connections["default"].ensure_connection()
71
76
  except OperationalError:
@@ -74,6 +79,12 @@ def _trigger_startup_notification(**_: object) -> None:
74
79
  _startup_notification()
75
80
 
76
81
 
82
+ def _is_running_migration_command() -> bool:
83
+ """Return ``True`` when Django's ``migrate`` command is executing."""
84
+
85
+ return len(sys.argv) > 1 and sys.argv[1] == "migrate"
86
+
87
+
77
88
  class NodesConfig(AppConfig):
78
89
  default_auto_field = "django.db.models.BigAutoField"
79
90
  name = "nodes"
nodes/models.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Iterable
4
4
  from copy import deepcopy
5
5
  from dataclasses import dataclass
6
6
  from django.db import models
7
+ from django.db.models import Q
7
8
  from django.db.utils import DatabaseError
8
9
  from django.db.models.signals import post_delete
9
10
  from django.dispatch import Signal, receiver
@@ -16,7 +17,7 @@ import base64
16
17
  from django.utils import timezone
17
18
  from django.utils.text import slugify
18
19
  from django.conf import settings
19
- from datetime import timedelta
20
+ from datetime import datetime, timedelta, timezone as datetime_timezone
20
21
  import uuid
21
22
  import os
22
23
  import shutil
@@ -294,7 +295,14 @@ class Node(Entity):
294
295
  """Return the node representing the current host if it exists."""
295
296
  mac = cls.get_current_mac()
296
297
  try:
297
- return cls.objects.filter(mac_address=mac).first()
298
+ node = cls.objects.filter(mac_address__iexact=mac).first()
299
+ if node:
300
+ return node
301
+ return (
302
+ cls.objects.filter(current_relation=cls.Relation.SELF)
303
+ .filter(Q(mac_address__isnull=True) | Q(mac_address=""))
304
+ .first()
305
+ )
298
306
  except DatabaseError:
299
307
  logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
300
308
  return None
@@ -481,7 +489,24 @@ class Node(Entity):
481
489
  security_dir.mkdir(parents=True, exist_ok=True)
482
490
  priv_path = security_dir / f"{self.public_endpoint}"
483
491
  pub_path = security_dir / f"{self.public_endpoint}.pub"
484
- if not priv_path.exists() or not pub_path.exists():
492
+ regenerate = not priv_path.exists() or not pub_path.exists()
493
+ if not regenerate:
494
+ key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
495
+ if key_max_age is not None:
496
+ try:
497
+ priv_mtime = datetime.fromtimestamp(
498
+ priv_path.stat().st_mtime, tz=datetime_timezone.utc
499
+ )
500
+ pub_mtime = datetime.fromtimestamp(
501
+ pub_path.stat().st_mtime, tz=datetime_timezone.utc
502
+ )
503
+ except OSError:
504
+ regenerate = True
505
+ else:
506
+ cutoff = timezone.now() - key_max_age
507
+ if priv_mtime < cutoff or pub_mtime < cutoff:
508
+ regenerate = True
509
+ if regenerate:
485
510
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
486
511
  private_bytes = private_key.private_bytes(
487
512
  encoding=serialization.Encoding.PEM,
@@ -494,8 +519,10 @@ class Node(Entity):
494
519
  )
495
520
  priv_path.write_bytes(private_bytes)
496
521
  pub_path.write_bytes(public_bytes)
497
- self.public_key = public_bytes.decode()
498
- self.save(update_fields=["public_key"])
522
+ public_text = public_bytes.decode()
523
+ if self.public_key != public_text:
524
+ self.public_key = public_text
525
+ self.save(update_fields=["public_key"])
499
526
  elif not self.public_key:
500
527
  self.public_key = pub_path.read_text()
501
528
  self.save(update_fields=["public_key"])
@@ -1088,8 +1115,8 @@ class NodeManager(Profile):
1088
1115
  )
1089
1116
 
1090
1117
  class Meta:
1091
- verbose_name = "Node Manager"
1092
- verbose_name_plural = "Node Managers"
1118
+ verbose_name = "Node Profile"
1119
+ verbose_name_plural = "Node Profiles"
1093
1120
 
1094
1121
  def __str__(self) -> str:
1095
1122
  owner = self.owner_display()
@@ -1542,6 +1569,7 @@ class NetMessage(Entity):
1542
1569
  payload = attachments if attachments is not None else self.attachments or []
1543
1570
  if not payload:
1544
1571
  return
1572
+
1545
1573
  try:
1546
1574
  objects = list(
1547
1575
  serializers.deserialize(