device-connect-server 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/PKG-INFO +1 -1
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/__main__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/cli.py +27 -4
- device_connect_server-0.2.4/device_connect_server/devctl/selector_cli.py +103 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/base.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/mongo.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/__init__.py +5 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/__main__.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/app.py +227 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/config.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/services/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/backend.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/bundles.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/services/cli_auth.py +251 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/credentials.py +22 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_acl.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_admin.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_backend.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_rpc.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nats_admin.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nats_backend.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/services/nats_rpc.py +189 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nsc.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/registry_client.py +28 -13
- device_connect_server-0.2.4/device_connect_server/portal/services/tokens.py +255 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/users.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/verification.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_acl.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_admin.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_backend.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_pki.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_rpc.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/admin/tenant_detail.html +390 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/auth/cli_approve.html +96 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/base.html +2 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/AGENTS.md.j2 +733 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/_token_secret.html +22 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/_tokens_table.html +53 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/page.html +128 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/dashboard.html +456 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_device_row.html +29 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_device_row_pair.html +56 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_live_detail.html +119 -0
- device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_live_table.html +32 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/devices/list.html +1 -15
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/login.html +5 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/signup.html +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/views/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/admin.py +4 -0
- device_connect_server-0.2.4/device_connect_server/portal/views/agent_api.py +1025 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/auth.py +33 -3
- device_connect_server-0.2.4/device_connect_server/portal/views/cli_auth.py +103 -0
- device_connect_server-0.2.4/device_connect_server/portal/views/coding_agents.py +190 -0
- device_connect_server-0.2.4/device_connect_server/portal/views/dashboard.py +319 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/devices.py +142 -13
- device_connect_server-0.2.4/device_connect_server/portalctl/__init__.py +5 -0
- device_connect_server-0.2.4/device_connect_server/portalctl/__main__.py +8 -0
- device_connect_server-0.2.4/device_connect_server/portalctl/admin_tokens.py +103 -0
- device_connect_server-0.2.4/device_connect_server/portalctl/cli.py +686 -0
- device_connect_server-0.2.4/device_connect_server/portalctl/streaming.py +109 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/client.py +90 -10
- device_connect_server-0.2.4/device_connect_server/registry/service/__init__.py +5 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/service/main.py +180 -7
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/service/registry.py +197 -10
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/acl.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/commissioning.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/credentials.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/base.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/etcd_store.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/__init__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/__main__.py +4 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/cli.py +27 -0
- device_connect_server-0.2.4/device_connect_server/statectl/operations_cli.py +297 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/PKG-INFO +1 -1
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/SOURCES.txt +20 -1
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/entry_points.txt +1 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/pyproject.toml +7 -2
- device_connect_server-0.2.2/device_connect_server/portal/__init__.py +0 -1
- device_connect_server-0.2.2/device_connect_server/portal/app.py +0 -136
- device_connect_server-0.2.2/device_connect_server/portal/services/__init__.py +0 -0
- device_connect_server-0.2.2/device_connect_server/portal/services/nats_rpc.py +0 -73
- device_connect_server-0.2.2/device_connect_server/portal/templates/admin/tenant_detail.html +0 -212
- device_connect_server-0.2.2/device_connect_server/portal/templates/dashboard.html +0 -227
- device_connect_server-0.2.2/device_connect_server/portal/templates/devices/_device_row.html +0 -13
- device_connect_server-0.2.2/device_connect_server/portal/templates/devices/_live_table.html +0 -169
- device_connect_server-0.2.2/device_connect_server/portal/views/__init__.py +0 -0
- device_connect_server-0.2.2/device_connect_server/portal/views/dashboard.py +0 -155
- device_connect_server-0.2.2/device_connect_server/registry/service/__init__.py +0 -1
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/LICENSE +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/README.md +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/static/portal.css +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/_health_results.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/_tenants_table.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/dashboard.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/health.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/setup.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/devices/detail.html +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/dependency_links.txt +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/requires.txt +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/top_level.txt +0 -0
- {device_connect_server-0.2.2 → device_connect_server-0.2.4}/setup.cfg +0 -0
{device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/__init__.py
RENAMED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
"""Device Connect Server — infrastructure extensions for Device Connect.
|
|
2
6
|
|
|
3
7
|
Device-side logic (DeviceRuntime, DeviceDriver, messaging, types) lives in
|
{device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/cli.py
RENAMED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
"""CLI for Device Connect device control.
|
|
2
6
|
|
|
3
7
|
This module provides the command-line interface for device operations:
|
|
@@ -570,9 +574,20 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
570
574
|
p_reg.add_argument("--broker", default=None, help="Broker URL")
|
|
571
575
|
p_reg.add_argument("--keepalive", action="store_true", help="Start heartbeat loop")
|
|
572
576
|
|
|
573
|
-
# discover
|
|
574
|
-
|
|
575
|
-
|
|
577
|
+
# mdns-scan: discover uncommissioned devices on the local network.
|
|
578
|
+
# Renamed from the historical ``discover`` verb so the selector-driven
|
|
579
|
+
# ``discover`` below (which queries the fleet, not the local network)
|
|
580
|
+
# can take the natural name.
|
|
581
|
+
p_scan = sub.add_parser(
|
|
582
|
+
"mdns-scan", help="Discover uncommissioned devices via mDNS",
|
|
583
|
+
aliases=["scan"],
|
|
584
|
+
)
|
|
585
|
+
p_scan.add_argument("--timeout", type=int, default=5, help="Timeout in seconds")
|
|
586
|
+
|
|
587
|
+
# Selector-driven fleet discovery (new). Registers ``discover`` and
|
|
588
|
+
# ``discover-labels`` as parser entries.
|
|
589
|
+
from device_connect_server.devctl import selector_cli
|
|
590
|
+
selector_cli.register_subparsers(sub)
|
|
576
591
|
|
|
577
592
|
# commission command
|
|
578
593
|
p_commission = sub.add_parser("commission", help="Commission a device with PIN")
|
|
@@ -613,9 +628,17 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
613
628
|
loop.stop()
|
|
614
629
|
print("\nbye!")
|
|
615
630
|
|
|
616
|
-
elif args.cmd
|
|
631
|
+
elif args.cmd in ("mdns-scan", "scan"):
|
|
617
632
|
asyncio.run(discover_devices(timeout=args.timeout))
|
|
618
633
|
|
|
634
|
+
elif args.cmd == "discover":
|
|
635
|
+
from device_connect_server.devctl import selector_cli
|
|
636
|
+
sys.exit(selector_cli.run_discover(args))
|
|
637
|
+
|
|
638
|
+
elif args.cmd == "discover-labels":
|
|
639
|
+
from device_connect_server.devctl import selector_cli
|
|
640
|
+
sys.exit(selector_cli.run_discover_labels(args))
|
|
641
|
+
|
|
619
642
|
elif args.cmd == "commission":
|
|
620
643
|
asyncio.run(
|
|
621
644
|
commission_device(
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""``devctl`` selector-driven discovery verbs.
|
|
6
|
+
|
|
7
|
+
Thin wrappers around ``device_connect_agent_tools.discover`` and
|
|
8
|
+
``discover_labels`` so operators can drive the same selector grammar
|
|
9
|
+
from a shell.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _connect(broker: str | None) -> None:
|
|
19
|
+
"""Best-effort connect to the messaging backend.
|
|
20
|
+
|
|
21
|
+
Reuses ``DEVICE_CONNECT_*`` and ``NATS_URL`` env vars when ``broker`` is
|
|
22
|
+
not given. Kept as a thin wrapper so all CLI verbs share the same
|
|
23
|
+
connect-or-fail semantics.
|
|
24
|
+
"""
|
|
25
|
+
from device_connect_agent_tools import connect
|
|
26
|
+
|
|
27
|
+
if broker:
|
|
28
|
+
connect(nats_url=broker)
|
|
29
|
+
else:
|
|
30
|
+
nats_url = os.getenv("NATS_URL") or os.getenv("DEVICE_CONNECT_NATS_URL")
|
|
31
|
+
if nats_url:
|
|
32
|
+
connect(nats_url=nats_url)
|
|
33
|
+
else:
|
|
34
|
+
connect()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pretty(data: Any) -> str:
|
|
38
|
+
"""Render a JSON payload for terminal output."""
|
|
39
|
+
return json.dumps(data, indent=2, sort_keys=True, default=str)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_discover(args: Any) -> int:
|
|
43
|
+
"""Execute ``devctl discover "<selector>"``."""
|
|
44
|
+
from device_connect_agent_tools import disconnect, discover
|
|
45
|
+
|
|
46
|
+
_connect(getattr(args, "broker", None))
|
|
47
|
+
try:
|
|
48
|
+
result = discover(
|
|
49
|
+
args.selector,
|
|
50
|
+
offset=int(args.offset or 0),
|
|
51
|
+
limit=int(args.limit or 200),
|
|
52
|
+
)
|
|
53
|
+
print(_pretty(result))
|
|
54
|
+
return 0 if "error" not in result else 1
|
|
55
|
+
finally:
|
|
56
|
+
try:
|
|
57
|
+
disconnect()
|
|
58
|
+
except Exception: # pragma: no cover
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_discover_labels(args: Any) -> int:
|
|
63
|
+
"""Execute ``devctl discover-labels [--key K]``."""
|
|
64
|
+
from device_connect_agent_tools import disconnect, discover_labels
|
|
65
|
+
|
|
66
|
+
_connect(getattr(args, "broker", None))
|
|
67
|
+
try:
|
|
68
|
+
result = discover_labels(
|
|
69
|
+
key=args.key,
|
|
70
|
+
offset=int(args.offset or 0),
|
|
71
|
+
limit=int(args.limit or 50),
|
|
72
|
+
)
|
|
73
|
+
print(_pretty(result))
|
|
74
|
+
return 0 if "error" not in result else 1
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
disconnect()
|
|
78
|
+
except Exception: # pragma: no cover
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_subparsers(sub: Any) -> None:
|
|
83
|
+
"""Attach the discover / discover-labels subparsers to a devctl parser."""
|
|
84
|
+
p = sub.add_parser(
|
|
85
|
+
"discover",
|
|
86
|
+
help="Resolve a selector to devices, functions, or events",
|
|
87
|
+
)
|
|
88
|
+
p.add_argument("selector", help="Selector expression (e.g. 'device(category:camera)')")
|
|
89
|
+
p.add_argument("--broker", default=None, help="Messaging broker URL")
|
|
90
|
+
p.add_argument("--offset", type=int, default=0, help="Pagination offset")
|
|
91
|
+
p.add_argument("--limit", type=int, default=200, help="Page size")
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser(
|
|
94
|
+
"discover-labels",
|
|
95
|
+
help="Browse fleet label vocabulary",
|
|
96
|
+
)
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"--key", default=None,
|
|
99
|
+
help="Axis-qualified label key (e.g. 'device.location') for per-key pagination",
|
|
100
|
+
)
|
|
101
|
+
p.add_argument("--broker", default=None, help="Messaging broker URL")
|
|
102
|
+
p.add_argument("--offset", type=int, default=0, help="Pagination offset")
|
|
103
|
+
p.add_argument("--limit", type=int, default=50, help="Page size")
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""aiohttp application factory with session middleware and route registration."""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import aiohttp_jinja2
|
|
12
|
+
import jinja2
|
|
13
|
+
from aiohttp import web
|
|
14
|
+
|
|
15
|
+
from . import config
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
20
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
21
|
+
|
|
22
|
+
# Routes that don't require authentication
|
|
23
|
+
PUBLIC_ROUTES = {"/", "/login", "/signup", "/api/login", "/api/signup"}
|
|
24
|
+
|
|
25
|
+
# Agent API namespace — Bearer-token authenticated, JSON-only errors.
|
|
26
|
+
AGENT_API_PREFIX = "/api/agent/v1/"
|
|
27
|
+
|
|
28
|
+
# Subpaths under the agent API that are intentionally public (no Bearer required).
|
|
29
|
+
# These power the browser-mediated CLI login flow.
|
|
30
|
+
AGENT_API_PUBLIC_SUBPATHS = ("auth/cli/init", "auth/cli/poll")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json_error(status: int, code: str, message: str) -> web.Response:
|
|
34
|
+
return web.json_response(
|
|
35
|
+
{"success": False, "error": {"code": code, "message": message}},
|
|
36
|
+
status=status,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@web.middleware
|
|
41
|
+
async def auth_middleware(request: web.Request, handler):
|
|
42
|
+
"""Authenticate browser sessions via cookie, agent API via Bearer token."""
|
|
43
|
+
path = request.path
|
|
44
|
+
|
|
45
|
+
# Allow public routes and static files
|
|
46
|
+
if path in PUBLIC_ROUTES or path.startswith("/static"):
|
|
47
|
+
return await handler(request)
|
|
48
|
+
|
|
49
|
+
# Agent API: Bearer token, JSON 401/403 — never HTML, never redirects.
|
|
50
|
+
if path.startswith(AGENT_API_PREFIX):
|
|
51
|
+
suffix = path[len(AGENT_API_PREFIX):]
|
|
52
|
+
if any(suffix == p or suffix.startswith(p + "/") for p in AGENT_API_PUBLIC_SUBPATHS):
|
|
53
|
+
return await handler(request)
|
|
54
|
+
|
|
55
|
+
from .services import tokens as tokens_svc
|
|
56
|
+
|
|
57
|
+
auth_header = request.headers.get("Authorization", "")
|
|
58
|
+
if not auth_header.startswith("Bearer "):
|
|
59
|
+
return _json_error(401, "missing_token", "Authorization: Bearer <token> required")
|
|
60
|
+
token = auth_header[len("Bearer "):].strip()
|
|
61
|
+
record = tokens_svc.verify_token(token)
|
|
62
|
+
if not record:
|
|
63
|
+
return _json_error(401, "invalid_token", "Token is invalid, revoked, or expired")
|
|
64
|
+
request["token"] = record
|
|
65
|
+
request["user"] = {
|
|
66
|
+
"username": record["username"],
|
|
67
|
+
"tenant": record["tenant"],
|
|
68
|
+
"role": record["role"],
|
|
69
|
+
}
|
|
70
|
+
return await handler(request)
|
|
71
|
+
|
|
72
|
+
# Browser session path
|
|
73
|
+
session = await _get_session(request)
|
|
74
|
+
if not session.get("username"):
|
|
75
|
+
# Preserve the requested URL so post-login redirect lands the user
|
|
76
|
+
# back on (e.g.) the CLI approval page — but only for top-level
|
|
77
|
+
# HTML navigations. Background htmx polls and JSON fetches under
|
|
78
|
+
# /api/ return HTML fragments or JSON, not full pages, so using
|
|
79
|
+
# them as the post-login destination dumps the user onto a
|
|
80
|
+
# chrome-less fragment. The dashboard's 10s poll on
|
|
81
|
+
# /api/devices/live was the original repro: portal restart ->
|
|
82
|
+
# session lost -> next poll redirected to /login with the poll
|
|
83
|
+
# URL as ``next`` -> after login the user landed on the raw
|
|
84
|
+
# fragment instead of the dashboard.
|
|
85
|
+
is_htmx = request.headers.get("HX-Request") == "true"
|
|
86
|
+
is_api = path.startswith("/api/")
|
|
87
|
+
if is_htmx:
|
|
88
|
+
# htmx swaps response bodies into the page, so a 302 to the
|
|
89
|
+
# login page would be injected as a fragment. Use htmx's own
|
|
90
|
+
# client-side redirect header instead.
|
|
91
|
+
resp = web.Response(status=200)
|
|
92
|
+
resp.headers["HX-Redirect"] = "/login"
|
|
93
|
+
return resp
|
|
94
|
+
if is_api:
|
|
95
|
+
# Background browser fetches (the JSON poll, row-html,
|
|
96
|
+
# live-detail, invoke) are raw fetch() calls that follow
|
|
97
|
+
# redirects: a 302 to /login resolves to the login page with
|
|
98
|
+
# r.ok === true and gets injected into a fragment slot. Return
|
|
99
|
+
# a real 401 so the client can never mistake the login page
|
|
100
|
+
# for fragment data; the client bounces the tab through the
|
|
101
|
+
# top-level login flow when it sees this.
|
|
102
|
+
return _json_error(401, "session_expired", "Session expired; log in again")
|
|
103
|
+
next_url = path
|
|
104
|
+
if request.query_string:
|
|
105
|
+
next_url = f"{path}?{request.query_string}"
|
|
106
|
+
from urllib.parse import quote
|
|
107
|
+
login_url = "/login?next=" + quote(next_url, safe="") if path != "/login" else "/login"
|
|
108
|
+
raise web.HTTPFound(login_url)
|
|
109
|
+
|
|
110
|
+
request["user"] = session
|
|
111
|
+
return await handler(request)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@web.middleware
|
|
115
|
+
async def admin_middleware(request: web.Request, handler):
|
|
116
|
+
"""Block non-admin users from /admin/* routes."""
|
|
117
|
+
if request.path.startswith("/admin") or request.path.startswith("/api/admin"):
|
|
118
|
+
session = await _get_session(request)
|
|
119
|
+
if session.get("role") != "admin":
|
|
120
|
+
raise web.HTTPForbidden(text="Admin access required")
|
|
121
|
+
return await handler(request)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _get_session(request: web.Request) -> dict:
|
|
125
|
+
"""Simple cookie-based session. Stores JSON in a signed cookie."""
|
|
126
|
+
import hashlib
|
|
127
|
+
import hmac
|
|
128
|
+
import json
|
|
129
|
+
|
|
130
|
+
cookie = request.cookies.get("portal_session", "")
|
|
131
|
+
if not cookie:
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
parts = cookie.split(".", 1)
|
|
136
|
+
if len(parts) != 2:
|
|
137
|
+
return {}
|
|
138
|
+
payload_b64, sig = parts
|
|
139
|
+
# Verify signature
|
|
140
|
+
expected_sig = hmac.new(
|
|
141
|
+
config.SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
|
|
142
|
+
).hexdigest()
|
|
143
|
+
if not hmac.compare_digest(sig, expected_sig):
|
|
144
|
+
return {}
|
|
145
|
+
payload = base64.b64decode(payload_b64).decode()
|
|
146
|
+
return json.loads(payload)
|
|
147
|
+
except Exception:
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def set_session(response: web.Response, data: dict):
|
|
152
|
+
"""Set session cookie on response."""
|
|
153
|
+
import hashlib
|
|
154
|
+
import hmac
|
|
155
|
+
import json
|
|
156
|
+
|
|
157
|
+
payload = base64.b64encode(json.dumps(data).encode()).decode()
|
|
158
|
+
sig = hmac.new(
|
|
159
|
+
config.SESSION_SECRET.encode(), payload.encode(), hashlib.sha256
|
|
160
|
+
).hexdigest()
|
|
161
|
+
cookie_value = f"{payload}.{sig}"
|
|
162
|
+
response.set_cookie(
|
|
163
|
+
"portal_session", cookie_value,
|
|
164
|
+
httponly=True, samesite="Lax", max_age=86400,
|
|
165
|
+
secure=config.SESSION_SECURE_COOKIE or None,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def clear_session(response: web.Response):
|
|
170
|
+
"""Clear session cookie."""
|
|
171
|
+
response.del_cookie("portal_session")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_app() -> web.Application:
|
|
175
|
+
"""Create and configure the portal application."""
|
|
176
|
+
app = web.Application(middlewares=[auth_middleware, admin_middleware])
|
|
177
|
+
|
|
178
|
+
# Setup Jinja2 templates
|
|
179
|
+
import json as _json
|
|
180
|
+
env = aiohttp_jinja2.setup(
|
|
181
|
+
app,
|
|
182
|
+
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
|
183
|
+
)
|
|
184
|
+
env.filters["tojson_pretty"] = lambda v: _json.dumps(v, indent=2, default=str)
|
|
185
|
+
|
|
186
|
+
# Static files
|
|
187
|
+
app.router.add_static("/static", STATIC_DIR, name="static")
|
|
188
|
+
|
|
189
|
+
# Register routes
|
|
190
|
+
from .views import (
|
|
191
|
+
auth, dashboard, devices, admin, agent_api,
|
|
192
|
+
cli_auth as cli_auth_view, coding_agents,
|
|
193
|
+
)
|
|
194
|
+
auth.setup_routes(app)
|
|
195
|
+
dashboard.setup_routes(app)
|
|
196
|
+
devices.setup_routes(app)
|
|
197
|
+
admin.setup_routes(app)
|
|
198
|
+
agent_api.setup_routes(app)
|
|
199
|
+
cli_auth_view.setup_routes(app)
|
|
200
|
+
coding_agents.setup_routes(app)
|
|
201
|
+
|
|
202
|
+
# Seed admin on startup
|
|
203
|
+
app.on_startup.append(_on_startup)
|
|
204
|
+
# Close the cached NATS invoke client on shutdown. Without this the
|
|
205
|
+
# socket leaks at graceful exit because the client is module-level
|
|
206
|
+
# state in nats_rpc, not tied to the aiohttp Application lifecycle.
|
|
207
|
+
app.on_cleanup.append(_on_cleanup)
|
|
208
|
+
|
|
209
|
+
return app
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _on_startup(app: web.Application):
|
|
213
|
+
"""Seed admin account on startup."""
|
|
214
|
+
try:
|
|
215
|
+
from .services.users import ensure_admin
|
|
216
|
+
ensure_admin()
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning("Could not seed admin account (etcd may not be ready): %s", e)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _on_cleanup(app: web.Application):
|
|
222
|
+
"""Release long-lived resources held at module scope."""
|
|
223
|
+
try:
|
|
224
|
+
from .services.nats_rpc import close_invoke_client
|
|
225
|
+
await close_invoke_client()
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning("Error closing cached NATS invoke client: %s", e)
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
"""Messaging backend abstraction — strategy pattern for NATS, Zenoh, and MQTT.
|
|
2
6
|
|
|
3
7
|
The admin selects a backend during bootstrap. All portal services
|