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.
Files changed (107) hide show
  1. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/PKG-INFO +1 -1
  2. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/__init__.py +4 -0
  3. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/__init__.py +4 -0
  4. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/__main__.py +4 -0
  5. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/devctl/cli.py +27 -4
  6. device_connect_server-0.2.4/device_connect_server/devctl/selector_cli.py +103 -0
  7. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/__init__.py +4 -0
  8. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/base.py +4 -0
  9. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/logging/mongo.py +4 -0
  10. device_connect_server-0.2.4/device_connect_server/portal/__init__.py +5 -0
  11. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/__main__.py +4 -0
  12. device_connect_server-0.2.4/device_connect_server/portal/app.py +227 -0
  13. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/config.py +4 -0
  14. device_connect_server-0.2.4/device_connect_server/portal/services/__init__.py +4 -0
  15. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/backend.py +4 -0
  16. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/bundles.py +4 -0
  17. device_connect_server-0.2.4/device_connect_server/portal/services/cli_auth.py +251 -0
  18. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/credentials.py +22 -0
  19. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_acl.py +4 -0
  20. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_admin.py +4 -0
  21. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_backend.py +4 -0
  22. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/mqtt_rpc.py +4 -0
  23. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nats_admin.py +4 -0
  24. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nats_backend.py +4 -0
  25. device_connect_server-0.2.4/device_connect_server/portal/services/nats_rpc.py +189 -0
  26. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/nsc.py +4 -0
  27. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/registry_client.py +28 -13
  28. device_connect_server-0.2.4/device_connect_server/portal/services/tokens.py +255 -0
  29. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/users.py +4 -0
  30. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/verification.py +4 -0
  31. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_acl.py +4 -0
  32. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_admin.py +4 -0
  33. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_backend.py +4 -0
  34. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_pki.py +4 -0
  35. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/services/zenoh_rpc.py +4 -0
  36. device_connect_server-0.2.4/device_connect_server/portal/templates/admin/tenant_detail.html +390 -0
  37. device_connect_server-0.2.4/device_connect_server/portal/templates/auth/cli_approve.html +96 -0
  38. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/base.html +2 -0
  39. device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/AGENTS.md.j2 +733 -0
  40. device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/_token_secret.html +22 -0
  41. device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/_tokens_table.html +53 -0
  42. device_connect_server-0.2.4/device_connect_server/portal/templates/coding_agents/page.html +128 -0
  43. device_connect_server-0.2.4/device_connect_server/portal/templates/dashboard.html +456 -0
  44. device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_device_row.html +29 -0
  45. device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_device_row_pair.html +56 -0
  46. device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_live_detail.html +119 -0
  47. device_connect_server-0.2.4/device_connect_server/portal/templates/devices/_live_table.html +32 -0
  48. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/devices/list.html +1 -15
  49. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/login.html +5 -0
  50. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/signup.html +4 -0
  51. device_connect_server-0.2.4/device_connect_server/portal/views/__init__.py +4 -0
  52. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/admin.py +4 -0
  53. device_connect_server-0.2.4/device_connect_server/portal/views/agent_api.py +1025 -0
  54. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/auth.py +33 -3
  55. device_connect_server-0.2.4/device_connect_server/portal/views/cli_auth.py +103 -0
  56. device_connect_server-0.2.4/device_connect_server/portal/views/coding_agents.py +190 -0
  57. device_connect_server-0.2.4/device_connect_server/portal/views/dashboard.py +319 -0
  58. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/views/devices.py +142 -13
  59. device_connect_server-0.2.4/device_connect_server/portalctl/__init__.py +5 -0
  60. device_connect_server-0.2.4/device_connect_server/portalctl/__main__.py +8 -0
  61. device_connect_server-0.2.4/device_connect_server/portalctl/admin_tokens.py +103 -0
  62. device_connect_server-0.2.4/device_connect_server/portalctl/cli.py +686 -0
  63. device_connect_server-0.2.4/device_connect_server/portalctl/streaming.py +109 -0
  64. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/__init__.py +4 -0
  65. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/client.py +90 -10
  66. device_connect_server-0.2.4/device_connect_server/registry/service/__init__.py +5 -0
  67. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/service/main.py +180 -7
  68. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/registry/service/registry.py +197 -10
  69. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/__init__.py +4 -0
  70. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/acl.py +4 -0
  71. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/commissioning.py +4 -0
  72. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/security/credentials.py +4 -0
  73. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/__init__.py +4 -0
  74. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/base.py +4 -0
  75. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/state/etcd_store.py +4 -0
  76. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/__init__.py +4 -0
  77. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/__main__.py +4 -0
  78. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/statectl/cli.py +27 -0
  79. device_connect_server-0.2.4/device_connect_server/statectl/operations_cli.py +297 -0
  80. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/PKG-INFO +1 -1
  81. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/SOURCES.txt +20 -1
  82. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/entry_points.txt +1 -0
  83. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/pyproject.toml +7 -2
  84. device_connect_server-0.2.2/device_connect_server/portal/__init__.py +0 -1
  85. device_connect_server-0.2.2/device_connect_server/portal/app.py +0 -136
  86. device_connect_server-0.2.2/device_connect_server/portal/services/__init__.py +0 -0
  87. device_connect_server-0.2.2/device_connect_server/portal/services/nats_rpc.py +0 -73
  88. device_connect_server-0.2.2/device_connect_server/portal/templates/admin/tenant_detail.html +0 -212
  89. device_connect_server-0.2.2/device_connect_server/portal/templates/dashboard.html +0 -227
  90. device_connect_server-0.2.2/device_connect_server/portal/templates/devices/_device_row.html +0 -13
  91. device_connect_server-0.2.2/device_connect_server/portal/templates/devices/_live_table.html +0 -169
  92. device_connect_server-0.2.2/device_connect_server/portal/views/__init__.py +0 -0
  93. device_connect_server-0.2.2/device_connect_server/portal/views/dashboard.py +0 -155
  94. device_connect_server-0.2.2/device_connect_server/registry/service/__init__.py +0 -1
  95. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/LICENSE +0 -0
  96. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/README.md +0 -0
  97. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/static/portal.css +0 -0
  98. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/_health_results.html +0 -0
  99. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/_tenants_table.html +0 -0
  100. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/dashboard.html +0 -0
  101. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/health.html +0 -0
  102. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/admin/setup.html +0 -0
  103. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server/portal/templates/devices/detail.html +0 -0
  104. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/dependency_links.txt +0 -0
  105. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/requires.txt +0 -0
  106. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/device_connect_server.egg-info/top_level.txt +0 -0
  107. {device_connect_server-0.2.2 → device_connect_server-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: device-connect-server
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Device Connect — edge device runtime with Zenoh/NATS messaging, D2D communication, and IoT orchestration
5
5
  Author-email: Arm <opensource@arm.com>
6
6
  License: Apache-2.0
@@ -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
@@ -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 control CLI for Device Connect.
2
6
 
3
7
  This module provides a command-line interface for interacting with
@@ -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
  """Entry point for running devctl as a module.
2
6
 
3
7
  Usage:
@@ -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 command
574
- p_discover = sub.add_parser("discover", help="Discover uncommissioned devices")
575
- p_discover.add_argument("--timeout", type=int, default=5, help="Timeout in seconds")
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 == "discover":
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")
@@ -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
  """Logging framework for Device Connect.
2
6
 
3
7
  This module provides pluggable audit logging with multiple backend support.
@@ -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
  """Abstract base class for audit logging.
2
6
 
3
7
  This module defines the AuditLogger interface for pluggable logging
@@ -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
  """MongoDB implementation of the AuditLogger.
2
6
 
3
7
  This module provides MongoAuditLogger, which stores audit logs in
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Device Connect Tenant Management Portal."""
@@ -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
  """Entry point: python -m device_connect_server.portal"""
2
6
 
3
7
  import logging
@@ -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
  """Portal configuration — paths, env vars, defaults."""
2
6
 
3
7
  import os
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2024-2026, Arm Limited and Contributors. All rights reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
@@ -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
@@ -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
  """Create and serve tenant credential bundles (.zip)."""
2
6
 
3
7
  import io