arthexis 0.1.24__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.
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/METADATA +35 -14
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/RECORD +30 -29
- config/settings.py +6 -3
- config/urls.py +2 -0
- core/admin.py +1 -186
- core/backends.py +3 -1
- core/models.py +74 -8
- core/system.py +67 -2
- core/views.py +0 -3
- nodes/admin.py +444 -251
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +211 -1
- nodes/urls.py +5 -0
- nodes/utils.py +9 -2
- nodes/views.py +128 -80
- ocpp/admin.py +190 -2
- ocpp/consumers.py +98 -0
- ocpp/models.py +271 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +108 -267
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +4 -2
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
577
|
+
direct_address = socket.gethostbyname(hostname)
|
|
322
578
|
except OSError:
|
|
323
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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()
|