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.
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -48,6 +48,7 @@ from .classifiers import run_default_classifiers
|
|
|
48
48
|
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
49
49
|
from django.db.utils import DatabaseError
|
|
50
50
|
|
|
51
|
+
from .admin import NodeAdmin
|
|
51
52
|
from .models import (
|
|
52
53
|
Node,
|
|
53
54
|
EmailOutbox,
|
|
@@ -67,7 +68,8 @@ from .backends import OutboxEmailBackend
|
|
|
67
68
|
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
68
69
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
69
70
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
70
|
-
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
|
|
71
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
|
|
72
|
+
from requests.exceptions import SSLError
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
class NodeBadgeColorTests(TestCase):
|
|
@@ -742,6 +744,59 @@ class NodeGetLocalTests(TestCase):
|
|
|
742
744
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
743
745
|
|
|
744
746
|
|
|
747
|
+
class NodeEnsureKeysTests(TestCase):
|
|
748
|
+
def setUp(self):
|
|
749
|
+
self.tempdir = TemporaryDirectory()
|
|
750
|
+
self.base = Path(self.tempdir.name)
|
|
751
|
+
self.override = override_settings(BASE_DIR=self.base)
|
|
752
|
+
self.override.enable()
|
|
753
|
+
self.node = Node.objects.create(
|
|
754
|
+
hostname="ensure-host",
|
|
755
|
+
address="127.0.0.1",
|
|
756
|
+
port=8000,
|
|
757
|
+
mac_address="00:11:22:33:44:55",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def tearDown(self):
|
|
761
|
+
self.override.disable()
|
|
762
|
+
self.tempdir.cleanup()
|
|
763
|
+
|
|
764
|
+
def test_regenerates_missing_keys(self):
|
|
765
|
+
self.node.ensure_keys()
|
|
766
|
+
security_dir = self.base / "security"
|
|
767
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
768
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
769
|
+
original_public = self.node.public_key
|
|
770
|
+
priv_path.unlink()
|
|
771
|
+
pub_path.unlink()
|
|
772
|
+
|
|
773
|
+
self.node.ensure_keys()
|
|
774
|
+
|
|
775
|
+
self.assertTrue(priv_path.exists())
|
|
776
|
+
self.assertTrue(pub_path.exists())
|
|
777
|
+
self.assertNotEqual(self.node.public_key, original_public)
|
|
778
|
+
|
|
779
|
+
def test_regenerates_outdated_keys(self):
|
|
780
|
+
self.node.ensure_keys()
|
|
781
|
+
security_dir = self.base / "security"
|
|
782
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
783
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
784
|
+
original_private = priv_path.read_bytes()
|
|
785
|
+
original_public = pub_path.read_bytes()
|
|
786
|
+
|
|
787
|
+
old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
|
|
788
|
+
os.utime(priv_path, (old_time, old_time))
|
|
789
|
+
os.utime(pub_path, (old_time, old_time))
|
|
790
|
+
|
|
791
|
+
with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
|
|
792
|
+
self.node.ensure_keys()
|
|
793
|
+
|
|
794
|
+
self.node.refresh_from_db()
|
|
795
|
+
self.assertNotEqual(priv_path.read_bytes(), original_private)
|
|
796
|
+
self.assertNotEqual(pub_path.read_bytes(), original_public)
|
|
797
|
+
self.assertNotEqual(self.node.public_key, original_public.decode())
|
|
798
|
+
|
|
799
|
+
|
|
745
800
|
class NodeInfoViewTests(TestCase):
|
|
746
801
|
def setUp(self):
|
|
747
802
|
self.mac = "02:00:00:00:00:01"
|
|
@@ -790,6 +845,16 @@ class NodeInfoViewTests(TestCase):
|
|
|
790
845
|
payload = response.json()
|
|
791
846
|
self.assertEqual(payload["port"], 8443)
|
|
792
847
|
|
|
848
|
+
def test_includes_role_in_payload(self):
|
|
849
|
+
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
850
|
+
self.node.role = role
|
|
851
|
+
self.node.save(update_fields=["role"])
|
|
852
|
+
|
|
853
|
+
response = self.client.get(self.url)
|
|
854
|
+
self.assertEqual(response.status_code, 200)
|
|
855
|
+
payload = response.json()
|
|
856
|
+
self.assertEqual(payload.get("role"), "Terminal")
|
|
857
|
+
|
|
793
858
|
|
|
794
859
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
795
860
|
def setUp(self):
|
|
@@ -1651,6 +1716,60 @@ class NodeAdminTests(TestCase):
|
|
|
1651
1716
|
response, reverse("admin:nodes_node_register_current")
|
|
1652
1717
|
)
|
|
1653
1718
|
|
|
1719
|
+
def test_apply_remote_node_info_updates_role(self):
|
|
1720
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1721
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1722
|
+
node = Node.objects.create(
|
|
1723
|
+
hostname="remote-node",
|
|
1724
|
+
address="10.0.0.20",
|
|
1725
|
+
port=8001,
|
|
1726
|
+
mac_address="00:11:22:33:44:aa",
|
|
1727
|
+
role=terminal,
|
|
1728
|
+
)
|
|
1729
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1730
|
+
|
|
1731
|
+
payload = {
|
|
1732
|
+
"hostname": node.hostname,
|
|
1733
|
+
"address": node.address,
|
|
1734
|
+
"port": node.port,
|
|
1735
|
+
"role": "Control",
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1739
|
+
node.refresh_from_db()
|
|
1740
|
+
|
|
1741
|
+
self.assertIn("role", changed)
|
|
1742
|
+
self.assertEqual(node.role, control)
|
|
1743
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1744
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1745
|
+
|
|
1746
|
+
def test_apply_remote_node_info_accepts_role_name_key(self):
|
|
1747
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1748
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1749
|
+
node = Node.objects.create(
|
|
1750
|
+
hostname="role-name-node",
|
|
1751
|
+
address="10.0.0.21",
|
|
1752
|
+
port=8002,
|
|
1753
|
+
mac_address="00:11:22:33:44:bb",
|
|
1754
|
+
role=terminal,
|
|
1755
|
+
)
|
|
1756
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1757
|
+
|
|
1758
|
+
payload = {
|
|
1759
|
+
"hostname": node.hostname,
|
|
1760
|
+
"address": node.address,
|
|
1761
|
+
"port": node.port,
|
|
1762
|
+
"role_name": "Control",
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1766
|
+
node.refresh_from_db()
|
|
1767
|
+
|
|
1768
|
+
self.assertIn("role", changed)
|
|
1769
|
+
self.assertEqual(node.role, control)
|
|
1770
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1771
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1772
|
+
|
|
1654
1773
|
@pytest.mark.feature("screenshot-poll")
|
|
1655
1774
|
@patch("nodes.admin.capture_screenshot")
|
|
1656
1775
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
@@ -1725,6 +1844,92 @@ class NodeAdminTests(TestCase):
|
|
|
1725
1844
|
mock_post.assert_called()
|
|
1726
1845
|
payload = json.loads(mock_post.call_args[1]["data"])
|
|
1727
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))
|
|
1728
1933
|
|
|
1729
1934
|
def test_proxy_link_displayed_for_remote_nodes(self):
|
|
1730
1935
|
Node.objects.create(
|
|
@@ -1737,6 +1942,51 @@ class NodeAdminTests(TestCase):
|
|
|
1737
1942
|
proxy_url = reverse("admin:nodes_node_proxy", args=[1])
|
|
1738
1943
|
self.assertContains(response, proxy_url)
|
|
1739
1944
|
|
|
1945
|
+
def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
|
|
1946
|
+
node_admin = admin.site._registry[Node]
|
|
1947
|
+
local_node = self._create_local_node()
|
|
1948
|
+
|
|
1949
|
+
link_html = node_admin.visit_link(local_node)
|
|
1950
|
+
|
|
1951
|
+
self.assertIn(reverse("admin:index"), link_html)
|
|
1952
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
1953
|
+
|
|
1954
|
+
def test_visit_link_prefers_remote_hostname_for_dashboard(self):
|
|
1955
|
+
node_admin = admin.site._registry[Node]
|
|
1956
|
+
remote = Node.objects.create(
|
|
1957
|
+
hostname="remote.example.com",
|
|
1958
|
+
address="198.51.100.20",
|
|
1959
|
+
port=8443,
|
|
1960
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1963
|
+
link_html = node_admin.visit_link(remote)
|
|
1964
|
+
|
|
1965
|
+
self.assertIn("https://remote.example.com:8443/admin/", link_html)
|
|
1966
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
1967
|
+
|
|
1968
|
+
def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
|
|
1969
|
+
node_admin = admin.site._registry[Node]
|
|
1970
|
+
remote = SimpleNamespace(
|
|
1971
|
+
public_endpoint="",
|
|
1972
|
+
address="",
|
|
1973
|
+
hostname="example.com/interface",
|
|
1974
|
+
port=8443,
|
|
1975
|
+
)
|
|
1976
|
+
|
|
1977
|
+
urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
|
|
1978
|
+
|
|
1979
|
+
self.assertIn(
|
|
1980
|
+
"https://example.com:8443/interface/nodes/proxy/session/",
|
|
1981
|
+
urls,
|
|
1982
|
+
)
|
|
1983
|
+
self.assertIn(
|
|
1984
|
+
"http://example.com:8443/interface/nodes/proxy/session/",
|
|
1985
|
+
urls,
|
|
1986
|
+
)
|
|
1987
|
+
combined = "".join(urls)
|
|
1988
|
+
self.assertNotIn("interface:8443", combined)
|
|
1989
|
+
|
|
1740
1990
|
|
|
1741
1991
|
@pytest.mark.feature("screenshot-poll")
|
|
1742
1992
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
@@ -2427,6 +2677,32 @@ class NodeProxyGatewayTests(TestCase):
|
|
|
2427
2677
|
second = self.client.get(parsed.path)
|
|
2428
2678
|
self.assertEqual(second.status_code, 410)
|
|
2429
2679
|
|
|
2680
|
+
def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
|
|
2681
|
+
payload = {
|
|
2682
|
+
"requester": str(uuid.uuid4()),
|
|
2683
|
+
"requester_mac": self.node.mac_address,
|
|
2684
|
+
"requester_public_key": self.node.public_key,
|
|
2685
|
+
"user": {
|
|
2686
|
+
"username": "proxy-user",
|
|
2687
|
+
"email": "proxy@example.com",
|
|
2688
|
+
"first_name": "Proxy",
|
|
2689
|
+
"last_name": "User",
|
|
2690
|
+
"is_staff": True,
|
|
2691
|
+
"is_superuser": True,
|
|
2692
|
+
"groups": [],
|
|
2693
|
+
"permissions": [],
|
|
2694
|
+
},
|
|
2695
|
+
"target": "/admin/",
|
|
2696
|
+
}
|
|
2697
|
+
body, signature = self._sign(payload)
|
|
2698
|
+
response = self.client.post(
|
|
2699
|
+
reverse("node-proxy-session"),
|
|
2700
|
+
data=body,
|
|
2701
|
+
content_type="application/json",
|
|
2702
|
+
HTTP_X_SIGNATURE=signature,
|
|
2703
|
+
)
|
|
2704
|
+
self.assertEqual(response.status_code, 200)
|
|
2705
|
+
|
|
2430
2706
|
def test_proxy_execute_lists_nodes(self):
|
|
2431
2707
|
Node.objects.create(
|
|
2432
2708
|
hostname="target",
|
|
@@ -3412,6 +3688,17 @@ class StartupHandlerTests(TestCase):
|
|
|
3412
3688
|
|
|
3413
3689
|
mock_start.assert_called_once()
|
|
3414
3690
|
|
|
3691
|
+
def test_handler_skips_during_migrate_command(self):
|
|
3692
|
+
import sys
|
|
3693
|
+
|
|
3694
|
+
from nodes.apps import _trigger_startup_notification
|
|
3695
|
+
|
|
3696
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
3697
|
+
with patch.object(sys, "argv", ["manage.py", "migrate"]):
|
|
3698
|
+
_trigger_startup_notification()
|
|
3699
|
+
|
|
3700
|
+
mock_start.assert_not_called()
|
|
3701
|
+
|
|
3415
3702
|
|
|
3416
3703
|
class NotificationManagerTests(TestCase):
|
|
3417
3704
|
def test_send_writes_trimmed_lines(self):
|
nodes/views.py
CHANGED
|
@@ -43,24 +43,68 @@ PROXY_TOKEN_TIMEOUT = 300
|
|
|
43
43
|
PROXY_CACHE_PREFIX = "nodes:proxy-session:"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def _load_signed_node(
|
|
46
|
+
def _load_signed_node(
|
|
47
|
+
request,
|
|
48
|
+
requester_id: str,
|
|
49
|
+
*,
|
|
50
|
+
mac_address: str | None = None,
|
|
51
|
+
public_key: str | None = None,
|
|
52
|
+
):
|
|
47
53
|
signature = request.headers.get("X-Signature")
|
|
48
54
|
if not signature:
|
|
49
55
|
return None, JsonResponse({"detail": "signature required"}, status=403)
|
|
50
|
-
node = Node.objects.filter(uuid=requester_id).first()
|
|
51
|
-
if not node or not node.public_key:
|
|
52
|
-
return None, JsonResponse({"detail": "unknown requester"}, status=403)
|
|
53
56
|
try:
|
|
54
|
-
|
|
55
|
-
public_key.verify(
|
|
56
|
-
base64.b64decode(signature),
|
|
57
|
-
request.body,
|
|
58
|
-
padding.PKCS1v15(),
|
|
59
|
-
hashes.SHA256(),
|
|
60
|
-
)
|
|
57
|
+
signature_bytes = base64.b64decode(signature)
|
|
61
58
|
except Exception:
|
|
62
59
|
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
63
|
-
|
|
60
|
+
|
|
61
|
+
candidates: list[Node] = []
|
|
62
|
+
seen: set[int] = set()
|
|
63
|
+
|
|
64
|
+
lookup_values: list[tuple[str, str]] = []
|
|
65
|
+
if requester_id:
|
|
66
|
+
lookup_values.append(("uuid", requester_id))
|
|
67
|
+
if mac_address:
|
|
68
|
+
lookup_values.append(("mac_address__iexact", mac_address))
|
|
69
|
+
if public_key:
|
|
70
|
+
lookup_values.append(("public_key", public_key))
|
|
71
|
+
|
|
72
|
+
for field, value in lookup_values:
|
|
73
|
+
node = Node.objects.filter(**{field: value}).first()
|
|
74
|
+
if not node or not node.public_key:
|
|
75
|
+
continue
|
|
76
|
+
if node.pk is not None and node.pk in seen:
|
|
77
|
+
continue
|
|
78
|
+
if node.pk is not None:
|
|
79
|
+
seen.add(node.pk)
|
|
80
|
+
candidates.append(node)
|
|
81
|
+
|
|
82
|
+
if not candidates:
|
|
83
|
+
return None, JsonResponse({"detail": "unknown requester"}, status=403)
|
|
84
|
+
|
|
85
|
+
for node in candidates:
|
|
86
|
+
try:
|
|
87
|
+
loaded_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
88
|
+
loaded_key.verify(
|
|
89
|
+
signature_bytes,
|
|
90
|
+
request.body,
|
|
91
|
+
padding.PKCS1v15(),
|
|
92
|
+
hashes.SHA256(),
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
continue
|
|
96
|
+
return node, None
|
|
97
|
+
|
|
98
|
+
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _clean_requester_hint(value, *, strip: bool = True) -> str | None:
|
|
102
|
+
if not isinstance(value, str):
|
|
103
|
+
return None
|
|
104
|
+
cleaned = value.strip() if strip else value
|
|
105
|
+
if not cleaned:
|
|
106
|
+
return None
|
|
107
|
+
return cleaned
|
|
64
108
|
|
|
65
109
|
|
|
66
110
|
def _sanitize_proxy_target(target: str | None, request) -> str:
|
|
@@ -320,6 +364,7 @@ def node_info(request):
|
|
|
320
364
|
"mac_address": node.mac_address,
|
|
321
365
|
"public_key": node.public_key,
|
|
322
366
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
367
|
+
"role": node.role.name if node.role_id else "",
|
|
323
368
|
}
|
|
324
369
|
|
|
325
370
|
if token:
|
|
@@ -588,26 +633,21 @@ def export_rfids(request):
|
|
|
588
633
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
589
634
|
|
|
590
635
|
requester = payload.get("requester")
|
|
591
|
-
signature = request.headers.get("X-Signature")
|
|
592
636
|
if not requester:
|
|
593
637
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
594
|
-
if not signature:
|
|
595
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
596
638
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
except Exception:
|
|
610
|
-
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
639
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
640
|
+
requester_public_key = _clean_requester_hint(
|
|
641
|
+
payload.get("requester_public_key"), strip=False
|
|
642
|
+
)
|
|
643
|
+
node, error_response = _load_signed_node(
|
|
644
|
+
request,
|
|
645
|
+
requester,
|
|
646
|
+
mac_address=requester_mac,
|
|
647
|
+
public_key=requester_public_key,
|
|
648
|
+
)
|
|
649
|
+
if error_response is not None:
|
|
650
|
+
return error_response
|
|
611
651
|
|
|
612
652
|
tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
613
653
|
|
|
@@ -627,26 +667,21 @@ def import_rfids(request):
|
|
|
627
667
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
628
668
|
|
|
629
669
|
requester = payload.get("requester")
|
|
630
|
-
signature = request.headers.get("X-Signature")
|
|
631
670
|
if not requester:
|
|
632
671
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
633
|
-
if not signature:
|
|
634
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
635
|
-
|
|
636
|
-
node = Node.objects.filter(uuid=requester).first()
|
|
637
|
-
if not node or not node.public_key:
|
|
638
|
-
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
639
672
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
673
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
674
|
+
requester_public_key = _clean_requester_hint(
|
|
675
|
+
payload.get("requester_public_key"), strip=False
|
|
676
|
+
)
|
|
677
|
+
node, error_response = _load_signed_node(
|
|
678
|
+
request,
|
|
679
|
+
requester,
|
|
680
|
+
mac_address=requester_mac,
|
|
681
|
+
public_key=requester_public_key,
|
|
682
|
+
)
|
|
683
|
+
if error_response is not None:
|
|
684
|
+
return error_response
|
|
650
685
|
|
|
651
686
|
rfids = payload.get("rfids", [])
|
|
652
687
|
if not isinstance(rfids, list):
|
|
@@ -703,7 +738,16 @@ def proxy_session(request):
|
|
|
703
738
|
if not requester:
|
|
704
739
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
705
740
|
|
|
706
|
-
|
|
741
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
742
|
+
requester_public_key = _clean_requester_hint(
|
|
743
|
+
payload.get("requester_public_key"), strip=False
|
|
744
|
+
)
|
|
745
|
+
node, error_response = _load_signed_node(
|
|
746
|
+
request,
|
|
747
|
+
requester,
|
|
748
|
+
mac_address=requester_mac,
|
|
749
|
+
public_key=requester_public_key,
|
|
750
|
+
)
|
|
707
751
|
if error_response is not None:
|
|
708
752
|
return error_response
|
|
709
753
|
|
|
@@ -843,7 +887,16 @@ def proxy_execute(request):
|
|
|
843
887
|
if not requester:
|
|
844
888
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
845
889
|
|
|
846
|
-
|
|
890
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
891
|
+
requester_public_key = _clean_requester_hint(
|
|
892
|
+
payload.get("requester_public_key"), strip=False
|
|
893
|
+
)
|
|
894
|
+
node, error_response = _load_signed_node(
|
|
895
|
+
request,
|
|
896
|
+
requester,
|
|
897
|
+
mac_address=requester_mac,
|
|
898
|
+
public_key=requester_public_key,
|
|
899
|
+
)
|
|
847
900
|
if error_response is not None:
|
|
848
901
|
return error_response
|
|
849
902
|
|
ocpp/admin.py
CHANGED
|
@@ -112,12 +112,19 @@ class LogViewAdminMixin:
|
|
|
112
112
|
|
|
113
113
|
@admin.register(ChargerConfiguration)
|
|
114
114
|
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
115
|
-
list_display = (
|
|
115
|
+
list_display = (
|
|
116
|
+
"charger_identifier",
|
|
117
|
+
"connector_display",
|
|
118
|
+
"origin_display",
|
|
119
|
+
"created_at",
|
|
120
|
+
)
|
|
116
121
|
list_filter = ("connector_id",)
|
|
117
122
|
search_fields = ("charger_identifier",)
|
|
118
123
|
readonly_fields = (
|
|
119
124
|
"charger_identifier",
|
|
120
125
|
"connector_id",
|
|
126
|
+
"origin_display",
|
|
127
|
+
"evcs_snapshot_at",
|
|
121
128
|
"created_at",
|
|
122
129
|
"updated_at",
|
|
123
130
|
"linked_chargers",
|
|
@@ -132,6 +139,8 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
132
139
|
"fields": (
|
|
133
140
|
"charger_identifier",
|
|
134
141
|
"connector_id",
|
|
142
|
+
"origin_display",
|
|
143
|
+
"evcs_snapshot_at",
|
|
135
144
|
"linked_chargers",
|
|
136
145
|
"created_at",
|
|
137
146
|
"updated_at",
|
|
@@ -185,11 +194,21 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
185
194
|
def raw_payload_display(self, obj):
|
|
186
195
|
return self._render_json(obj.raw_payload)
|
|
187
196
|
|
|
197
|
+
@admin.display(description="Origin")
|
|
198
|
+
def origin_display(self, obj):
|
|
199
|
+
if obj.evcs_snapshot_at:
|
|
200
|
+
return "EVCS"
|
|
201
|
+
return "Local"
|
|
202
|
+
|
|
203
|
+
def save_model(self, request, obj, form, change):
|
|
204
|
+
obj.evcs_snapshot_at = None
|
|
205
|
+
super().save_model(request, obj, form, change)
|
|
206
|
+
|
|
188
207
|
|
|
189
208
|
@admin.register(Location)
|
|
190
209
|
class LocationAdmin(EntityModelAdmin):
|
|
191
210
|
form = LocationAdminForm
|
|
192
|
-
list_display = ("name", "latitude", "longitude")
|
|
211
|
+
list_display = ("name", "zone", "contract_type", "latitude", "longitude")
|
|
193
212
|
change_form_template = "admin/ocpp/location/change_form.html"
|
|
194
213
|
|
|
195
214
|
|
|
@@ -752,6 +771,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
752
771
|
level=messages.ERROR,
|
|
753
772
|
)
|
|
754
773
|
continue
|
|
774
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
775
|
+
if tx_obj is not None:
|
|
776
|
+
self.message_user(
|
|
777
|
+
request,
|
|
778
|
+
(
|
|
779
|
+
f"{charger}: reset skipped because a session is active; "
|
|
780
|
+
"stop the session first."
|
|
781
|
+
),
|
|
782
|
+
level=messages.WARNING,
|
|
783
|
+
)
|
|
784
|
+
continue
|
|
755
785
|
message_id = uuid.uuid4().hex
|
|
756
786
|
msg = json.dumps([
|
|
757
787
|
2,
|
ocpp/consumers.py
CHANGED
|
@@ -791,6 +791,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
791
791
|
connector_id=connector_value,
|
|
792
792
|
configuration_keys=normalized_entries,
|
|
793
793
|
unknown_keys=unknown_values,
|
|
794
|
+
evcs_snapshot_at=timezone.now(),
|
|
794
795
|
raw_payload=raw_payload,
|
|
795
796
|
)
|
|
796
797
|
Charger.objects.filter(charger_id=self.charger_id).update(
|