arthexis 0.1.23__py3-none-any.whl → 0.1.25__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/models.py CHANGED
@@ -14,6 +14,7 @@ from core.fields import SigilLongAutoField, SigilShortAutoField
14
14
  import re
15
15
  import json
16
16
  import base64
17
+ import ipaddress
17
18
  from django.utils import timezone
18
19
  from django.utils.text import slugify
19
20
  from django.conf import settings
@@ -25,6 +26,7 @@ import socket
25
26
  import stat
26
27
  import subprocess
27
28
  from pathlib import Path
29
+ from urllib.parse import urlparse, urlunsplit
28
30
  from utils import revision
29
31
  from core.notifications import notify_async
30
32
  from django.core.exceptions import ValidationError
@@ -205,7 +207,14 @@ class Node(Entity):
205
207
  SELF = "SELF", "Self"
206
208
 
207
209
  hostname = models.CharField(max_length=100)
208
- address = models.GenericIPAddressField()
210
+ network_hostname = models.CharField(max_length=253, blank=True)
211
+ ipv4_address = models.GenericIPAddressField(
212
+ protocol="IPv4", blank=True, null=True
213
+ )
214
+ ipv6_address = models.GenericIPAddressField(
215
+ protocol="IPv6", blank=True, null=True
216
+ )
217
+ address = models.GenericIPAddressField(blank=True, null=True)
209
218
  mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
210
219
  port = models.PositiveIntegerField(default=8000)
211
220
  message_queue_length = models.PositiveSmallIntegerField(
@@ -265,6 +274,212 @@ class Node(Entity):
265
274
  def __str__(self) -> str: # pragma: no cover - simple representation
266
275
  return f"{self.hostname}:{self.port}"
267
276
 
277
+ @staticmethod
278
+ def _ip_preference(ip_value: str) -> tuple[int, str]:
279
+ """Return a sort key favouring globally routable addresses."""
280
+
281
+ try:
282
+ parsed = ipaddress.ip_address(ip_value)
283
+ except ValueError:
284
+ return (3, ip_value)
285
+
286
+ if parsed.is_global:
287
+ return (0, ip_value)
288
+
289
+ if parsed.is_loopback or parsed.is_link_local:
290
+ return (2, ip_value)
291
+
292
+ if parsed.is_private:
293
+ return (2, ip_value)
294
+
295
+ return (1, ip_value)
296
+
297
+ @classmethod
298
+ def _select_preferred_ip(cls, addresses: Iterable[str]) -> str | None:
299
+ """Return the preferred IP from ``addresses`` when available."""
300
+
301
+ best: tuple[int, str] | None = None
302
+ for candidate in addresses:
303
+ candidate = (candidate or "").strip()
304
+ if not candidate:
305
+ continue
306
+ score = cls._ip_preference(candidate)
307
+ if best is None or score < best:
308
+ best = score
309
+ return best[1] if best else None
310
+
311
+ @classmethod
312
+ def _resolve_ip_addresses(
313
+ cls, *hosts: str, include_ipv4: bool = True, include_ipv6: bool = True
314
+ ) -> tuple[list[str], list[str]]:
315
+ """Resolve ``hosts`` into IPv4 and IPv6 address lists."""
316
+
317
+ ipv4: list[str] = []
318
+ ipv6: list[str] = []
319
+
320
+ for host in hosts:
321
+ host = (host or "").strip()
322
+ if not host:
323
+ continue
324
+ try:
325
+ info = socket.getaddrinfo(
326
+ host,
327
+ None,
328
+ socket.AF_UNSPEC,
329
+ socket.SOCK_STREAM,
330
+ )
331
+ except OSError:
332
+ continue
333
+ for family, _, _, _, sockaddr in info:
334
+ if family == socket.AF_INET and include_ipv4:
335
+ value = sockaddr[0]
336
+ if value not in ipv4:
337
+ ipv4.append(value)
338
+ elif family == socket.AF_INET6 and include_ipv6:
339
+ value = sockaddr[0]
340
+ if value not in ipv6:
341
+ ipv6.append(value)
342
+
343
+ return ipv4, ipv6
344
+
345
+ def get_remote_host_candidates(self) -> list[str]:
346
+ """Return host strings that may reach this node."""
347
+
348
+ values: list[str] = []
349
+ for attr in (
350
+ "network_hostname",
351
+ "hostname",
352
+ "ipv6_address",
353
+ "ipv4_address",
354
+ "address",
355
+ "public_endpoint",
356
+ ):
357
+ value = getattr(self, attr, "") or ""
358
+ value = value.strip()
359
+ if value and value not in values:
360
+ values.append(value)
361
+
362
+ resolved_ipv6: list[str] = []
363
+ resolved_ipv4: list[str] = []
364
+ for host in list(values):
365
+ if host.startswith("http://") or host.startswith("https://"):
366
+ continue
367
+ try:
368
+ ipaddress.ip_address(host)
369
+ except ValueError:
370
+ ipv4, ipv6 = self._resolve_ip_addresses(host)
371
+ for candidate in ipv6:
372
+ if candidate not in values and candidate not in resolved_ipv6:
373
+ resolved_ipv6.append(candidate)
374
+ for candidate in ipv4:
375
+ if candidate not in values and candidate not in resolved_ipv4:
376
+ resolved_ipv4.append(candidate)
377
+ values.extend(resolved_ipv6)
378
+ values.extend(resolved_ipv4)
379
+ return values
380
+
381
+ def get_primary_contact(self) -> str:
382
+ """Return the first reachable host for this node."""
383
+
384
+ for host in self.get_remote_host_candidates():
385
+ if host:
386
+ return host
387
+ return ""
388
+
389
+ def iter_remote_urls(self, path: str):
390
+ """Yield potential remote URLs for ``path`` on this node."""
391
+
392
+ host_candidates = self.get_remote_host_candidates()
393
+ default_port = self.port or 8000
394
+ normalized_path = path if path.startswith("/") else f"/{path}"
395
+ seen: set[str] = set()
396
+
397
+ for host in host_candidates:
398
+ host = host.strip()
399
+ if not host:
400
+ continue
401
+ base_path = ""
402
+ formatted_host = host
403
+ port_override: int | None = None
404
+
405
+ if "://" in host:
406
+ parsed = urlparse(host)
407
+ netloc = parsed.netloc or parsed.path
408
+ base_path = (parsed.path or "").rstrip("/")
409
+ combined_path = (
410
+ f"{base_path}{normalized_path}" if base_path else normalized_path
411
+ )
412
+ primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
413
+ if primary not in seen:
414
+ seen.add(primary)
415
+ yield primary
416
+ if parsed.scheme == "https":
417
+ fallback = urlunsplit(("http", netloc, combined_path, "", ""))
418
+ if fallback not in seen:
419
+ seen.add(fallback)
420
+ yield fallback
421
+ elif parsed.scheme == "http":
422
+ alternate = urlunsplit(("https", netloc, combined_path, "", ""))
423
+ if alternate not in seen:
424
+ seen.add(alternate)
425
+ yield alternate
426
+ continue
427
+
428
+ if host.startswith("[") and "]" in host:
429
+ end = host.index("]")
430
+ core_host = host[1:end]
431
+ remainder = host[end + 1 :]
432
+ if remainder.startswith(":"):
433
+ remainder = remainder[1:]
434
+ port_part, sep, path_tail = remainder.partition("/")
435
+ if port_part:
436
+ try:
437
+ port_override = int(port_part)
438
+ except ValueError:
439
+ port_override = None
440
+ if sep:
441
+ base_path = f"/{path_tail}".rstrip("/")
442
+ elif "/" in remainder:
443
+ _, _, path_tail = remainder.partition("/")
444
+ base_path = f"/{path_tail}".rstrip("/")
445
+ formatted_host = f"[{core_host}]"
446
+ else:
447
+ if "/" in host:
448
+ host_only, _, path_tail = host.partition("/")
449
+ formatted_host = host_only or host
450
+ base_path = f"/{path_tail}".rstrip("/")
451
+ try:
452
+ ip_obj = ipaddress.ip_address(formatted_host)
453
+ except ValueError:
454
+ parts = formatted_host.rsplit(":", 1)
455
+ if len(parts) == 2 and parts[1].isdigit():
456
+ formatted_host = parts[0]
457
+ port_override = int(parts[1])
458
+ try:
459
+ ip_obj = ipaddress.ip_address(formatted_host)
460
+ except ValueError:
461
+ ip_obj = None
462
+ else:
463
+ if ip_obj.version == 6 and not formatted_host.startswith("["):
464
+ formatted_host = f"[{formatted_host}]"
465
+
466
+ effective_port = port_override if port_override is not None else default_port
467
+ combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
468
+
469
+ for scheme, scheme_default_port in (("https", 443), ("http", 80)):
470
+ base = f"{scheme}://{formatted_host}"
471
+ if effective_port and (
472
+ port_override is not None or effective_port != scheme_default_port
473
+ ):
474
+ explicit = f"{base}:{effective_port}{combined_path}"
475
+ if explicit not in seen:
476
+ seen.add(explicit)
477
+ yield explicit
478
+ candidate = f"{base}{combined_path}"
479
+ if candidate not in seen:
480
+ seen.add(candidate)
481
+ yield candidate
482
+
268
483
  @staticmethod
269
484
  def get_current_mac() -> str:
270
485
  """Return the MAC address of the current host."""
@@ -317,10 +532,59 @@ class Node(Entity):
317
532
  )
318
533
  hostname_override = hostname_override.strip()
319
534
  hostname = hostname_override or socket.gethostname()
535
+
536
+ network_hostname = os.environ.get("NODE_PUBLIC_HOSTNAME", "").strip()
537
+ if not network_hostname:
538
+ fqdn = socket.getfqdn(hostname)
539
+ if fqdn and "." in fqdn:
540
+ network_hostname = fqdn
541
+
542
+ ipv4_override = os.environ.get("NODE_PUBLIC_IPV4", "").strip()
543
+ ipv6_override = os.environ.get("NODE_PUBLIC_IPV6", "").strip()
544
+
545
+ ipv4_candidates: list[str] = []
546
+ ipv6_candidates: list[str] = []
547
+
548
+ for override, version in ((ipv4_override, 4), (ipv6_override, 6)):
549
+ override = override.strip()
550
+ if not override:
551
+ continue
552
+ try:
553
+ parsed = ipaddress.ip_address(override)
554
+ except ValueError:
555
+ continue
556
+ if parsed.version == version:
557
+ if version == 4 and override not in ipv4_candidates:
558
+ ipv4_candidates.append(override)
559
+ elif version == 6 and override not in ipv6_candidates:
560
+ ipv6_candidates.append(override)
561
+
562
+ resolve_hosts: list[str] = []
563
+ for value in (network_hostname, hostname_override, hostname):
564
+ value = (value or "").strip()
565
+ if value and value not in resolve_hosts:
566
+ resolve_hosts.append(value)
567
+
568
+ resolved_ipv4, resolved_ipv6 = cls._resolve_ip_addresses(*resolve_hosts)
569
+ for ip_value in resolved_ipv4:
570
+ if ip_value not in ipv4_candidates:
571
+ ipv4_candidates.append(ip_value)
572
+ for ip_value in resolved_ipv6:
573
+ if ip_value not in ipv6_candidates:
574
+ ipv6_candidates.append(ip_value)
575
+
320
576
  try:
321
- address = socket.gethostbyname(hostname)
577
+ direct_address = socket.gethostbyname(hostname)
322
578
  except OSError:
323
- address = "127.0.0.1"
579
+ direct_address = ""
580
+
581
+ if direct_address and direct_address not in ipv4_candidates:
582
+ ipv4_candidates.append(direct_address)
583
+
584
+ ipv4_address = cls._select_preferred_ip(ipv4_candidates)
585
+ ipv6_address = cls._select_preferred_ip(ipv6_candidates)
586
+
587
+ preferred_contact = ipv4_address or ipv6_address or direct_address or "127.0.0.1"
324
588
  port = int(os.environ.get("PORT", 8000))
325
589
  base_path = str(settings.BASE_DIR)
326
590
  ver_path = Path(settings.BASE_DIR) / "VERSION"
@@ -338,7 +602,10 @@ class Node(Entity):
338
602
  node = cls.objects.filter(public_endpoint=slug).first()
339
603
  defaults = {
340
604
  "hostname": hostname,
341
- "address": address,
605
+ "network_hostname": network_hostname,
606
+ "ipv4_address": ipv4_address,
607
+ "ipv6_address": ipv6_address,
608
+ "address": preferred_contact,
342
609
  "port": port,
343
610
  "base_path": base_path,
344
611
  "installed_version": installed_version,
@@ -416,7 +683,10 @@ class Node(Entity):
416
683
 
417
684
  payload = {
418
685
  "hostname": self.hostname,
686
+ "network_hostname": self.network_hostname,
419
687
  "address": self.address,
688
+ "ipv4_address": self.ipv4_address,
689
+ "ipv6_address": self.ipv6_address,
420
690
  "port": self.port,
421
691
  "mac_address": self.mac_address,
422
692
  "public_key": self.public_key,
@@ -433,17 +703,18 @@ class Node(Entity):
433
703
 
434
704
  peers = Node.objects.exclude(pk=self.pk)
435
705
  for peer in peers:
436
- host_candidates: list[str] = []
437
- if peer.address:
438
- host_candidates.append(peer.address)
439
- if peer.hostname and peer.hostname not in host_candidates:
440
- host_candidates.append(peer.hostname)
706
+ host_candidates = peer.get_remote_host_candidates()
441
707
  port = peer.port or 8000
442
708
  urls: list[str] = []
443
709
  for host in host_candidates:
444
710
  host = host.strip()
445
711
  if not host:
446
712
  continue
713
+ if host.startswith("http://") or host.startswith("https://"):
714
+ normalized = host.rstrip("/")
715
+ if normalized not in urls:
716
+ urls.append(normalized)
717
+ continue
447
718
  if ":" in host and not host.startswith("["):
448
719
  host = f"[{host}]"
449
720
  http_url = (
@@ -1928,20 +2199,25 @@ class NetMessage(Entity):
1928
2199
  if signature:
1929
2200
  headers["X-Signature"] = signature
1930
2201
  success = False
1931
- try:
1932
- response = requests.post(
1933
- f"http://{node.address}:{node.port}/nodes/net-message/",
1934
- data=payload_json,
1935
- headers=headers,
1936
- timeout=1,
1937
- )
1938
- success = bool(response.ok)
1939
- except Exception:
1940
- logger.exception(
1941
- "Failed to propagate NetMessage %s to node %s",
1942
- self.pk,
1943
- node.pk,
1944
- )
2202
+ for url in node.iter_remote_urls("/nodes/net-message/"):
2203
+ try:
2204
+ response = requests.post(
2205
+ url,
2206
+ data=payload_json,
2207
+ headers=headers,
2208
+ timeout=1,
2209
+ )
2210
+ success = bool(response.ok)
2211
+ except Exception:
2212
+ logger.exception(
2213
+ "Failed to propagate NetMessage %s to node %s via %s",
2214
+ self.pk,
2215
+ node.pk,
2216
+ url,
2217
+ )
2218
+ continue
2219
+ if success:
2220
+ break
1945
2221
  if success:
1946
2222
  self.clear_queue_for_node(node)
1947
2223
  else:
nodes/tasks.py CHANGED
@@ -83,25 +83,22 @@ def poll_unreachable_upstream() -> None:
83
83
  for upstream in upstream_nodes:
84
84
  if not upstream.public_key:
85
85
  continue
86
- host = (upstream.address or upstream.hostname or "").strip()
87
- if not host:
88
- continue
89
- if ":" in host and not host.startswith("["):
90
- host = f"[{host}]"
91
- port = upstream.port or 8000
92
- if port in {80, 443}:
93
- url = f"http://{host}/nodes/net-message/pull/"
94
- else:
95
- url = f"http://{host}:{port}/nodes/net-message/pull/"
96
- try:
97
- response = requests.post(url, data=payload_json, headers=headers, timeout=5)
98
- except Exception as exc:
99
- logger.warning("Polling upstream node %s failed: %s", upstream.pk, exc)
100
- continue
101
- if not response.ok:
86
+ response = None
87
+ for url in upstream.iter_remote_urls("/nodes/net-message/pull/"):
88
+ try:
89
+ response = requests.post(
90
+ url, data=payload_json, headers=headers, timeout=5
91
+ )
92
+ except Exception as exc:
93
+ logger.warning("Polling upstream node %s via %s failed: %s", upstream.pk, url, exc)
94
+ continue
95
+ if response.ok:
96
+ break
102
97
  logger.warning(
103
98
  "Upstream node %s returned status %s", upstream.pk, response.status_code
104
99
  )
100
+ response = None
101
+ if response is None or not response.ok:
105
102
  continue
106
103
  try:
107
104
  body = response.json()