arthexis 0.1.23__py3-none-any.whl → 0.1.24__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.23.dist-info → arthexis-0.1.24.dist-info}/METADATA +5 -5
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/RECORD +17 -17
- config/settings.py +4 -0
- core/admin.py +139 -27
- core/models.py +543 -204
- core/tasks.py +25 -0
- nodes/admin.py +152 -172
- nodes/tests.py +80 -129
- nodes/urls.py +6 -0
- nodes/views.py +520 -0
- ocpp/admin.py +541 -175
- ocpp/models.py +28 -0
- ocpp/tasks.py +336 -1
- pages/views.py +60 -30
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/top_level.txt +0 -0
core/tasks.py
CHANGED
|
@@ -405,3 +405,28 @@ def run_client_report_schedule(schedule_id: int) -> None:
|
|
|
405
405
|
except Exception:
|
|
406
406
|
logger.exception("ClientReportSchedule %s failed", schedule_id)
|
|
407
407
|
raise
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@shared_task
|
|
411
|
+
def ensure_recurring_client_reports() -> None:
|
|
412
|
+
"""Ensure scheduled consumer reports run for the current period."""
|
|
413
|
+
|
|
414
|
+
from core.models import ClientReportSchedule
|
|
415
|
+
|
|
416
|
+
reference = timezone.localdate()
|
|
417
|
+
schedules = ClientReportSchedule.objects.filter(
|
|
418
|
+
periodicity__in=[
|
|
419
|
+
ClientReportSchedule.PERIODICITY_DAILY,
|
|
420
|
+
ClientReportSchedule.PERIODICITY_WEEKLY,
|
|
421
|
+
ClientReportSchedule.PERIODICITY_MONTHLY,
|
|
422
|
+
]
|
|
423
|
+
).prefetch_related("chargers")
|
|
424
|
+
|
|
425
|
+
for schedule in schedules:
|
|
426
|
+
try:
|
|
427
|
+
schedule.generate_missing_reports(reference=reference)
|
|
428
|
+
except Exception:
|
|
429
|
+
logger.exception(
|
|
430
|
+
"Automatic consumer report generation failed for schedule %s",
|
|
431
|
+
schedule.pk,
|
|
432
|
+
)
|
nodes/admin.py
CHANGED
|
@@ -60,6 +60,7 @@ from .models import (
|
|
|
60
60
|
)
|
|
61
61
|
from . import dns as dns_utils
|
|
62
62
|
from core.models import RFID
|
|
63
|
+
from ocpp.models import Charger, Location
|
|
63
64
|
from core.user_data import EntityModelAdmin
|
|
64
65
|
|
|
65
66
|
|
|
@@ -237,7 +238,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
237
238
|
"relation",
|
|
238
239
|
"last_seen",
|
|
239
240
|
"visit_link",
|
|
240
|
-
"proxy_link",
|
|
241
241
|
)
|
|
242
242
|
search_fields = ("hostname", "address", "mac_address")
|
|
243
243
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
@@ -287,6 +287,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
287
287
|
"register_visitor",
|
|
288
288
|
"run_task",
|
|
289
289
|
"take_screenshots",
|
|
290
|
+
"discover_charge_points",
|
|
290
291
|
"import_rfids_from_selected",
|
|
291
292
|
"export_rfids_to_selected",
|
|
292
293
|
]
|
|
@@ -296,16 +297,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
296
297
|
def relation(self, obj):
|
|
297
298
|
return obj.get_current_relation_display()
|
|
298
299
|
|
|
299
|
-
@admin.display(description=_("Proxy"))
|
|
300
|
-
def proxy_link(self, obj):
|
|
301
|
-
if not obj or obj.is_local:
|
|
302
|
-
return ""
|
|
303
|
-
try:
|
|
304
|
-
url = reverse("admin:nodes_node_proxy", args=[obj.pk])
|
|
305
|
-
except NoReverseMatch:
|
|
306
|
-
return ""
|
|
307
|
-
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
308
|
-
|
|
309
300
|
@admin.display(description=_("Visit"))
|
|
310
301
|
def visit_link(self, obj):
|
|
311
302
|
if not obj:
|
|
@@ -372,11 +363,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
372
363
|
self.admin_site.admin_view(self.update_selected_progress),
|
|
373
364
|
name="nodes_node_update_selected_progress",
|
|
374
365
|
),
|
|
375
|
-
path(
|
|
376
|
-
"<int:node_id>/proxy/",
|
|
377
|
-
self.admin_site.admin_view(self.proxy_node),
|
|
378
|
-
name="nodes_node_proxy",
|
|
379
|
-
),
|
|
380
366
|
]
|
|
381
367
|
return custom + urls
|
|
382
368
|
|
|
@@ -409,162 +395,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
409
395
|
)
|
|
410
396
|
return response
|
|
411
397
|
|
|
412
|
-
def _load_local_private_key(self, node):
|
|
413
|
-
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
414
|
-
priv_path = security_dir / f"{node.public_endpoint}"
|
|
415
|
-
if not priv_path.exists():
|
|
416
|
-
return None, _("Local node private key not found.")
|
|
417
|
-
try:
|
|
418
|
-
return (
|
|
419
|
-
serialization.load_pem_private_key(
|
|
420
|
-
priv_path.read_bytes(), password=None
|
|
421
|
-
),
|
|
422
|
-
"",
|
|
423
|
-
)
|
|
424
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
425
|
-
return None, str(exc)
|
|
426
|
-
|
|
427
|
-
def _build_proxy_payload(self, request, local_node):
|
|
428
|
-
user = request.user
|
|
429
|
-
payload = {
|
|
430
|
-
"requester": str(local_node.uuid),
|
|
431
|
-
"user": {
|
|
432
|
-
"username": user.get_username(),
|
|
433
|
-
"email": user.email or "",
|
|
434
|
-
"first_name": user.first_name or "",
|
|
435
|
-
"last_name": user.last_name or "",
|
|
436
|
-
"is_staff": user.is_staff,
|
|
437
|
-
"is_superuser": user.is_superuser,
|
|
438
|
-
"groups": list(user.groups.values_list("name", flat=True)),
|
|
439
|
-
"permissions": sorted(user.get_all_permissions()),
|
|
440
|
-
},
|
|
441
|
-
"target": reverse("admin:index"),
|
|
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
|
|
449
|
-
return payload
|
|
450
|
-
|
|
451
|
-
def _start_proxy_session(self, request, node):
|
|
452
|
-
if node.is_local:
|
|
453
|
-
return {"ok": False, "message": _("Local node cannot be proxied.")}
|
|
454
|
-
|
|
455
|
-
local_node = Node.get_local()
|
|
456
|
-
if local_node is None:
|
|
457
|
-
try:
|
|
458
|
-
local_node, _ = Node.register_current()
|
|
459
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
460
|
-
return {"ok": False, "message": str(exc)}
|
|
461
|
-
|
|
462
|
-
private_key, error = self._load_local_private_key(local_node)
|
|
463
|
-
if private_key is None:
|
|
464
|
-
return {"ok": False, "message": error}
|
|
465
|
-
|
|
466
|
-
payload = self._build_proxy_payload(request, local_node)
|
|
467
|
-
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
468
|
-
try:
|
|
469
|
-
signature = private_key.sign(
|
|
470
|
-
body.encode(),
|
|
471
|
-
padding.PKCS1v15(),
|
|
472
|
-
hashes.SHA256(),
|
|
473
|
-
)
|
|
474
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
475
|
-
return {"ok": False, "message": str(exc)}
|
|
476
|
-
|
|
477
|
-
headers = {
|
|
478
|
-
"Content-Type": "application/json",
|
|
479
|
-
"X-Signature": base64.b64encode(signature).decode(),
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
last_error = ""
|
|
483
|
-
redirect_codes = {301, 302, 303, 307, 308}
|
|
484
|
-
|
|
485
|
-
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
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
|
-
}
|
|
541
|
-
|
|
542
|
-
return {
|
|
543
|
-
"ok": False,
|
|
544
|
-
"message": last_error or "Unable to initiate proxy.",
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
def proxy_node(self, request, node_id):
|
|
548
|
-
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
549
|
-
if not node:
|
|
550
|
-
raise Http404
|
|
551
|
-
if not self.has_view_permission(request):
|
|
552
|
-
raise PermissionDenied
|
|
553
|
-
result = self._start_proxy_session(request, node)
|
|
554
|
-
if not result.get("ok"):
|
|
555
|
-
message = result.get("message") or _("Unable to proxy node.")
|
|
556
|
-
self.message_user(request, message, messages.ERROR)
|
|
557
|
-
return redirect("admin:nodes_node_changelist")
|
|
558
|
-
|
|
559
|
-
context = {
|
|
560
|
-
**self.admin_site.each_context(request),
|
|
561
|
-
"opts": self.model._meta,
|
|
562
|
-
"node": node,
|
|
563
|
-
"frame_url": result.get("login_url"),
|
|
564
|
-
"expires": result.get("expires"),
|
|
565
|
-
}
|
|
566
|
-
return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
|
|
567
|
-
|
|
568
398
|
@admin.action(description="Register Visitor")
|
|
569
399
|
def register_visitor(self, request, queryset=None):
|
|
570
400
|
return self.register_visitor_view(request)
|
|
@@ -1230,6 +1060,156 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1230
1060
|
|
|
1231
1061
|
return self._render_rfid_sync(request, "export", results)
|
|
1232
1062
|
|
|
1063
|
+
@admin.action(description=_("Discover Charge Points"))
|
|
1064
|
+
def discover_charge_points(self, request, queryset):
|
|
1065
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1066
|
+
if error:
|
|
1067
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1068
|
+
return
|
|
1069
|
+
|
|
1070
|
+
nodes = [node for node in queryset if not local_node.pk or node.pk != local_node.pk]
|
|
1071
|
+
if not nodes:
|
|
1072
|
+
self.message_user(request, _("No remote nodes selected."), level=messages.WARNING)
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
payload = json.dumps(
|
|
1076
|
+
{"requester": str(local_node.uuid)},
|
|
1077
|
+
separators=(",", ":"),
|
|
1078
|
+
sort_keys=True,
|
|
1079
|
+
)
|
|
1080
|
+
signature = self._sign_payload(private_key, payload)
|
|
1081
|
+
headers = {
|
|
1082
|
+
"Content-Type": "application/json",
|
|
1083
|
+
"X-Signature": signature,
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
created = 0
|
|
1087
|
+
updated = 0
|
|
1088
|
+
errors: list[str] = []
|
|
1089
|
+
|
|
1090
|
+
for node in nodes:
|
|
1091
|
+
url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
|
|
1092
|
+
try:
|
|
1093
|
+
response = requests.post(url, data=payload, headers=headers, timeout=5)
|
|
1094
|
+
except RequestException as exc:
|
|
1095
|
+
errors.append(f"{node}: {exc}")
|
|
1096
|
+
continue
|
|
1097
|
+
|
|
1098
|
+
if response.status_code != 200:
|
|
1099
|
+
errors.append(f"{node}: {response.status_code} {response.text}")
|
|
1100
|
+
continue
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
data = response.json()
|
|
1104
|
+
except ValueError:
|
|
1105
|
+
errors.append(f"{node}: invalid JSON response")
|
|
1106
|
+
continue
|
|
1107
|
+
|
|
1108
|
+
for entry in data.get("chargers", []):
|
|
1109
|
+
applied = self._apply_remote_charger_payload(node, entry)
|
|
1110
|
+
if applied == "created":
|
|
1111
|
+
created += 1
|
|
1112
|
+
elif applied == "updated":
|
|
1113
|
+
updated += 1
|
|
1114
|
+
|
|
1115
|
+
if created or updated:
|
|
1116
|
+
summary = _("Imported %(created)s new and %(updated)s existing charge point(s).") % {
|
|
1117
|
+
"created": created,
|
|
1118
|
+
"updated": updated,
|
|
1119
|
+
}
|
|
1120
|
+
self.message_user(request, summary, level=messages.SUCCESS)
|
|
1121
|
+
if errors:
|
|
1122
|
+
for error in errors:
|
|
1123
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1124
|
+
|
|
1125
|
+
def _apply_remote_charger_payload(self, node, payload: Mapping) -> str | None:
|
|
1126
|
+
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
1127
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
connector_value = payload.get("connector_id")
|
|
1131
|
+
if connector_value in ("", None):
|
|
1132
|
+
connector_value = None
|
|
1133
|
+
elif isinstance(connector_value, str):
|
|
1134
|
+
try:
|
|
1135
|
+
connector_value = int(connector_value)
|
|
1136
|
+
except ValueError:
|
|
1137
|
+
connector_value = None
|
|
1138
|
+
|
|
1139
|
+
charger, created = Charger.objects.get_or_create(
|
|
1140
|
+
charger_id=serial,
|
|
1141
|
+
connector_id=connector_value,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
location_obj = None
|
|
1145
|
+
location_payload = payload.get("location")
|
|
1146
|
+
if isinstance(location_payload, Mapping):
|
|
1147
|
+
name = location_payload.get("name")
|
|
1148
|
+
if name:
|
|
1149
|
+
location_obj, _ = Location.objects.get_or_create(name=name)
|
|
1150
|
+
simple_fields = [
|
|
1151
|
+
"latitude",
|
|
1152
|
+
"longitude",
|
|
1153
|
+
"zone",
|
|
1154
|
+
"contract_type",
|
|
1155
|
+
]
|
|
1156
|
+
for field in simple_fields:
|
|
1157
|
+
value = location_payload.get(field)
|
|
1158
|
+
setattr(location_obj, field, value)
|
|
1159
|
+
location_obj.save()
|
|
1160
|
+
|
|
1161
|
+
datetime_fields = [
|
|
1162
|
+
"firmware_timestamp",
|
|
1163
|
+
"last_heartbeat",
|
|
1164
|
+
"availability_state_updated_at",
|
|
1165
|
+
"availability_requested_at",
|
|
1166
|
+
"availability_request_status_at",
|
|
1167
|
+
"diagnostics_timestamp",
|
|
1168
|
+
"last_status_timestamp",
|
|
1169
|
+
]
|
|
1170
|
+
|
|
1171
|
+
updates: dict[str, object] = {
|
|
1172
|
+
"node_origin": node,
|
|
1173
|
+
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
1174
|
+
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
1175
|
+
"last_online_at": timezone.now(),
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
simple_fields = [
|
|
1179
|
+
"display_name",
|
|
1180
|
+
"language",
|
|
1181
|
+
"public_display",
|
|
1182
|
+
"require_rfid",
|
|
1183
|
+
"firmware_status",
|
|
1184
|
+
"firmware_status_info",
|
|
1185
|
+
"last_status",
|
|
1186
|
+
"last_error_code",
|
|
1187
|
+
"last_status_vendor_info",
|
|
1188
|
+
"availability_state",
|
|
1189
|
+
"availability_requested_state",
|
|
1190
|
+
"availability_request_status",
|
|
1191
|
+
"availability_request_details",
|
|
1192
|
+
"temperature",
|
|
1193
|
+
"temperature_unit",
|
|
1194
|
+
"diagnostics_status",
|
|
1195
|
+
"diagnostics_location",
|
|
1196
|
+
]
|
|
1197
|
+
for field in simple_fields:
|
|
1198
|
+
updates[field] = payload.get(field)
|
|
1199
|
+
|
|
1200
|
+
if location_obj is not None:
|
|
1201
|
+
updates["location"] = location_obj
|
|
1202
|
+
|
|
1203
|
+
for field in datetime_fields:
|
|
1204
|
+
value = payload.get(field)
|
|
1205
|
+
updates[field] = parse_datetime(value) if value else None
|
|
1206
|
+
|
|
1207
|
+
for field in ("last_meter_values",):
|
|
1208
|
+
updates[field] = payload.get(field) or {}
|
|
1209
|
+
|
|
1210
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1211
|
+
return "created" if created else "updated"
|
|
1212
|
+
|
|
1233
1213
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1234
1214
|
extra_context = extra_context or {}
|
|
1235
1215
|
if object_id:
|
nodes/tests.py
CHANGED
|
@@ -66,6 +66,7 @@ from .models import (
|
|
|
66
66
|
)
|
|
67
67
|
from .backends import OutboxEmailBackend
|
|
68
68
|
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
69
|
+
from ocpp.models import Charger
|
|
69
70
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
70
71
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
71
72
|
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
|
|
@@ -1816,132 +1817,6 @@ class NodeAdminTests(TestCase):
|
|
|
1816
1817
|
self.assertEqual(response.status_code, 200)
|
|
1817
1818
|
self.assertContains(response, "data:image/png;base64")
|
|
1818
1819
|
|
|
1819
|
-
@patch("nodes.admin.requests.post")
|
|
1820
|
-
def test_proxy_view_uses_remote_login_url(self, mock_post):
|
|
1821
|
-
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1822
|
-
local_node = Node.objects.get()
|
|
1823
|
-
remote = Node.objects.create(
|
|
1824
|
-
hostname="remote",
|
|
1825
|
-
address="192.0.2.10",
|
|
1826
|
-
port=8443,
|
|
1827
|
-
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1828
|
-
)
|
|
1829
|
-
mock_post.return_value = SimpleNamespace(
|
|
1830
|
-
ok=True,
|
|
1831
|
-
json=lambda: {
|
|
1832
|
-
"login_url": "https://remote.example/nodes/proxy/login/token",
|
|
1833
|
-
"expires": "2025-01-01T00:00:00",
|
|
1834
|
-
},
|
|
1835
|
-
status_code=200,
|
|
1836
|
-
text="ok",
|
|
1837
|
-
)
|
|
1838
|
-
response = self.client.get(
|
|
1839
|
-
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1840
|
-
)
|
|
1841
|
-
self.assertEqual(response.status_code, 200)
|
|
1842
|
-
self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
|
|
1843
|
-
self.assertContains(response, "<iframe", html=False)
|
|
1844
|
-
mock_post.assert_called()
|
|
1845
|
-
payload = json.loads(mock_post.call_args[1]["data"])
|
|
1846
|
-
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1847
|
-
self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
|
|
1848
|
-
self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
|
|
1849
|
-
|
|
1850
|
-
@patch("nodes.admin.requests.post")
|
|
1851
|
-
def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
|
|
1852
|
-
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1853
|
-
remote = Node.objects.create(
|
|
1854
|
-
hostname="remote-https",
|
|
1855
|
-
address="198.51.100.20",
|
|
1856
|
-
port=443,
|
|
1857
|
-
mac_address="aa:bb:cc:dd:ee:10",
|
|
1858
|
-
)
|
|
1859
|
-
local_node = Node.get_local()
|
|
1860
|
-
success_response = SimpleNamespace(
|
|
1861
|
-
ok=True,
|
|
1862
|
-
json=lambda: {
|
|
1863
|
-
"login_url": "http://remote.example/nodes/proxy/login/token",
|
|
1864
|
-
"expires": "2025-01-01T00:00:00",
|
|
1865
|
-
},
|
|
1866
|
-
status_code=200,
|
|
1867
|
-
text="ok",
|
|
1868
|
-
)
|
|
1869
|
-
mock_post.side_effect = [
|
|
1870
|
-
SSLError("wrong version number"),
|
|
1871
|
-
success_response,
|
|
1872
|
-
]
|
|
1873
|
-
|
|
1874
|
-
response = self.client.get(
|
|
1875
|
-
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1876
|
-
)
|
|
1877
|
-
|
|
1878
|
-
self.assertEqual(response.status_code, 200)
|
|
1879
|
-
self.assertEqual(mock_post.call_count, 2)
|
|
1880
|
-
first_url = mock_post.call_args_list[0].args[0]
|
|
1881
|
-
second_url = mock_post.call_args_list[1].args[0]
|
|
1882
|
-
self.assertTrue(first_url.startswith("https://"))
|
|
1883
|
-
self.assertTrue(second_url.startswith("http://"))
|
|
1884
|
-
self.assertIn("/nodes/proxy/session/", second_url)
|
|
1885
|
-
payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
|
|
1886
|
-
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1887
|
-
self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
|
|
1888
|
-
self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
|
|
1889
|
-
|
|
1890
|
-
@patch("nodes.admin.requests.post")
|
|
1891
|
-
def test_proxy_view_retries_post_after_redirect(self, mock_post):
|
|
1892
|
-
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1893
|
-
remote = Node.objects.create(
|
|
1894
|
-
hostname="redirect-node",
|
|
1895
|
-
public_endpoint="http://remote.example",
|
|
1896
|
-
address="198.51.100.30",
|
|
1897
|
-
mac_address="aa:bb:cc:dd:ee:20",
|
|
1898
|
-
)
|
|
1899
|
-
|
|
1900
|
-
redirect_response = SimpleNamespace(
|
|
1901
|
-
status_code=301,
|
|
1902
|
-
ok=True,
|
|
1903
|
-
text="redirect",
|
|
1904
|
-
headers={"Location": "https://remote.example/nodes/proxy/session/"},
|
|
1905
|
-
)
|
|
1906
|
-
success_response = SimpleNamespace(
|
|
1907
|
-
status_code=200,
|
|
1908
|
-
ok=True,
|
|
1909
|
-
text="ok",
|
|
1910
|
-
headers={},
|
|
1911
|
-
json=lambda: {
|
|
1912
|
-
"login_url": "https://remote.example/nodes/proxy/login/token",
|
|
1913
|
-
"expires": "2025-01-01T00:00:00",
|
|
1914
|
-
},
|
|
1915
|
-
)
|
|
1916
|
-
|
|
1917
|
-
mock_post.side_effect = [redirect_response, success_response]
|
|
1918
|
-
|
|
1919
|
-
response = self.client.get(
|
|
1920
|
-
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1921
|
-
)
|
|
1922
|
-
|
|
1923
|
-
self.assertEqual(response.status_code, 200)
|
|
1924
|
-
self.assertEqual(mock_post.call_count, 2)
|
|
1925
|
-
|
|
1926
|
-
first_call_kwargs = mock_post.call_args_list[0].kwargs
|
|
1927
|
-
self.assertFalse(first_call_kwargs.get("allow_redirects", True))
|
|
1928
|
-
|
|
1929
|
-
second_url = mock_post.call_args_list[1].args[0]
|
|
1930
|
-
self.assertEqual(second_url, "https://remote.example/nodes/proxy/session/")
|
|
1931
|
-
second_call_kwargs = mock_post.call_args_list[1].kwargs
|
|
1932
|
-
self.assertFalse(second_call_kwargs.get("allow_redirects", True))
|
|
1933
|
-
|
|
1934
|
-
def test_proxy_link_displayed_for_remote_nodes(self):
|
|
1935
|
-
Node.objects.create(
|
|
1936
|
-
hostname="remote",
|
|
1937
|
-
address="203.0.113.1",
|
|
1938
|
-
port=8000,
|
|
1939
|
-
mac_address="aa:aa:aa:aa:aa:01",
|
|
1940
|
-
)
|
|
1941
|
-
response = self.client.get(reverse("admin:nodes_node_changelist"))
|
|
1942
|
-
proxy_url = reverse("admin:nodes_node_proxy", args=[1])
|
|
1943
|
-
self.assertContains(response, proxy_url)
|
|
1944
|
-
|
|
1945
1820
|
def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
|
|
1946
1821
|
node_admin = admin.site._registry[Node]
|
|
1947
1822
|
local_node = self._create_local_node()
|
|
@@ -1974,14 +1849,14 @@ class NodeAdminTests(TestCase):
|
|
|
1974
1849
|
port=8443,
|
|
1975
1850
|
)
|
|
1976
1851
|
|
|
1977
|
-
urls = list(node_admin._iter_remote_urls(remote, "/nodes/
|
|
1852
|
+
urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
|
|
1978
1853
|
|
|
1979
1854
|
self.assertIn(
|
|
1980
|
-
"https://example.com:8443/interface/nodes/
|
|
1855
|
+
"https://example.com:8443/interface/nodes/info/",
|
|
1981
1856
|
urls,
|
|
1982
1857
|
)
|
|
1983
1858
|
self.assertIn(
|
|
1984
|
-
"http://example.com:8443/interface/nodes/
|
|
1859
|
+
"http://example.com:8443/interface/nodes/info/",
|
|
1985
1860
|
urls,
|
|
1986
1861
|
)
|
|
1987
1862
|
combined = "".join(urls)
|
|
@@ -3605,6 +3480,82 @@ class NetMessageSignatureTests(TestCase):
|
|
|
3605
3480
|
self.assertTrue(signature_one)
|
|
3606
3481
|
self.assertTrue(signature_two)
|
|
3607
3482
|
self.assertNotEqual(signature_one, signature_two)
|
|
3483
|
+
|
|
3484
|
+
|
|
3485
|
+
class NetworkChargerActionSecurityTests(TestCase):
|
|
3486
|
+
def setUp(self):
|
|
3487
|
+
self.client = Client()
|
|
3488
|
+
self.local_node = Node.objects.create(
|
|
3489
|
+
hostname="local-node",
|
|
3490
|
+
address="127.0.0.1",
|
|
3491
|
+
port=8000,
|
|
3492
|
+
mac_address="00:aa:bb:cc:dd:10",
|
|
3493
|
+
public_endpoint="local-endpoint",
|
|
3494
|
+
)
|
|
3495
|
+
self.authorized_node = Node.objects.create(
|
|
3496
|
+
hostname="authorized-node",
|
|
3497
|
+
address="127.0.0.2",
|
|
3498
|
+
port=8001,
|
|
3499
|
+
mac_address="00:aa:bb:cc:dd:11",
|
|
3500
|
+
public_endpoint="authorized-endpoint",
|
|
3501
|
+
)
|
|
3502
|
+
self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
|
|
3503
|
+
"unauthorized-node",
|
|
3504
|
+
mac_suffix=0x12,
|
|
3505
|
+
)
|
|
3506
|
+
self.charger = Charger.objects.create(
|
|
3507
|
+
charger_id="SECURE-TEST-1",
|
|
3508
|
+
allow_remote=True,
|
|
3509
|
+
manager_node=self.authorized_node,
|
|
3510
|
+
node_origin=self.local_node,
|
|
3511
|
+
)
|
|
3512
|
+
|
|
3513
|
+
def _create_signed_node(self, hostname: str, *, mac_suffix: int):
|
|
3514
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3515
|
+
public_bytes = key.public_key().public_bytes(
|
|
3516
|
+
encoding=serialization.Encoding.PEM,
|
|
3517
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3518
|
+
)
|
|
3519
|
+
node = Node.objects.create(
|
|
3520
|
+
hostname=hostname,
|
|
3521
|
+
address="10.0.0.{:d}".format(mac_suffix),
|
|
3522
|
+
port=8020,
|
|
3523
|
+
mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
|
|
3524
|
+
public_key=public_bytes.decode(),
|
|
3525
|
+
public_endpoint=f"{hostname}-endpoint",
|
|
3526
|
+
)
|
|
3527
|
+
return node, key
|
|
3528
|
+
|
|
3529
|
+
def test_rejects_requests_from_unmanaged_nodes(self):
|
|
3530
|
+
url = reverse("node-network-charger-action")
|
|
3531
|
+
payload = {
|
|
3532
|
+
"requester": str(self.unauthorized_node.uuid),
|
|
3533
|
+
"charger_id": self.charger.charger_id,
|
|
3534
|
+
"action": "reset",
|
|
3535
|
+
}
|
|
3536
|
+
body = json.dumps(payload).encode()
|
|
3537
|
+
signature = self.unauthorized_key.sign(
|
|
3538
|
+
body,
|
|
3539
|
+
padding.PKCS1v15(),
|
|
3540
|
+
hashes.SHA256(),
|
|
3541
|
+
)
|
|
3542
|
+
headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
|
|
3543
|
+
|
|
3544
|
+
with patch.object(Node, "get_local", return_value=self.local_node):
|
|
3545
|
+
response = self.client.post(
|
|
3546
|
+
url,
|
|
3547
|
+
data=body,
|
|
3548
|
+
content_type="application/json",
|
|
3549
|
+
**headers,
|
|
3550
|
+
)
|
|
3551
|
+
|
|
3552
|
+
self.assertEqual(response.status_code, 403)
|
|
3553
|
+
self.assertEqual(
|
|
3554
|
+
response.json().get("detail"),
|
|
3555
|
+
"requester does not manage this charger",
|
|
3556
|
+
)
|
|
3557
|
+
|
|
3558
|
+
|
|
3608
3559
|
class StartupNotificationTests(TestCase):
|
|
3609
3560
|
def test_startup_notification_uses_hostname_and_revision(self):
|
|
3610
3561
|
from nodes.apps import _startup_notification
|
nodes/urls.py
CHANGED
|
@@ -11,6 +11,12 @@ urlpatterns = [
|
|
|
11
11
|
path("net-message/pull/", views.net_message_pull, name="net-message-pull"),
|
|
12
12
|
path("rfid/export/", views.export_rfids, name="node-rfid-export"),
|
|
13
13
|
path("rfid/import/", views.import_rfids, name="node-rfid-import"),
|
|
14
|
+
path("network/chargers/", views.network_chargers, name="node-network-chargers"),
|
|
15
|
+
path(
|
|
16
|
+
"network/chargers/action/",
|
|
17
|
+
views.network_charger_action,
|
|
18
|
+
name="node-network-charger-action",
|
|
19
|
+
),
|
|
14
20
|
path("proxy/session/", views.proxy_session, name="node-proxy-session"),
|
|
15
21
|
path("proxy/login/<str:token>/", views.proxy_login, name="node-proxy-login"),
|
|
16
22
|
path("proxy/execute/", views.proxy_execute, name="node-proxy-execute"),
|