constellation-node-sdk 1.0.1__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.
- constellation_node_sdk/__init__.py +123 -0
- constellation_node_sdk/gate/__init__.py +47 -0
- constellation_node_sdk/gate/client.py +108 -0
- constellation_node_sdk/gate/config.py +153 -0
- constellation_node_sdk/gate/errors.py +17 -0
- constellation_node_sdk/gate/policy.py +70 -0
- constellation_node_sdk/gate/registration.py +144 -0
- constellation_node_sdk/orchestrator/__init__.py +3 -0
- constellation_node_sdk/orchestrator/base.py +57 -0
- constellation_node_sdk/orchestrator/merge.py +47 -0
- constellation_node_sdk/orchestrator/packet_builder.py +49 -0
- constellation_node_sdk/orchestrator/retry.py +61 -0
- constellation_node_sdk/orchestrator/state.py +78 -0
- constellation_node_sdk/orchestrator/step_executor.py +87 -0
- constellation_node_sdk/py.typed +0 -0
- constellation_node_sdk/runtime/__init__.py +52 -0
- constellation_node_sdk/runtime/app.py +274 -0
- constellation_node_sdk/runtime/config.py +307 -0
- constellation_node_sdk/runtime/errors.py +112 -0
- constellation_node_sdk/runtime/execution.py +249 -0
- constellation_node_sdk/runtime/handlers.py +45 -0
- constellation_node_sdk/runtime/inbound_policy.py +86 -0
- constellation_node_sdk/runtime/lifecycle.py +29 -0
- constellation_node_sdk/runtime/observability.py +72 -0
- constellation_node_sdk/runtime/preflight.py +122 -0
- constellation_node_sdk/security/__init__.py +36 -0
- constellation_node_sdk/security/delegation.py +57 -0
- constellation_node_sdk/security/errors.py +25 -0
- constellation_node_sdk/security/signing.py +121 -0
- constellation_node_sdk/security/validation.py +230 -0
- constellation_node_sdk/security/verification.py +107 -0
- constellation_node_sdk/transport/__init__.py +88 -0
- constellation_node_sdk/transport/codec.py +21 -0
- constellation_node_sdk/transport/errors.py +41 -0
- constellation_node_sdk/transport/hashing.py +73 -0
- constellation_node_sdk/transport/hop_trace.py +378 -0
- constellation_node_sdk/transport/lineage.py +28 -0
- constellation_node_sdk/transport/models.py +447 -0
- constellation_node_sdk/transport/packet.py +331 -0
- constellation_node_sdk/transport/provenance.py +53 -0
- constellation_node_sdk/transport/tenant.py +76 -0
- constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
- constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
- constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
- constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from constellation_node_sdk.security.signing import sign_transport_packet
|
|
8
|
+
from constellation_node_sdk.security.validation import validate_transport_packet
|
|
9
|
+
from constellation_node_sdk.transport.hop_trace import make_execution_hop, make_response_hop
|
|
10
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
11
|
+
|
|
12
|
+
from .handlers import get_handler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_signing_material(
|
|
16
|
+
*,
|
|
17
|
+
signing_key: bytes | str | None,
|
|
18
|
+
signing_private_key: str | None,
|
|
19
|
+
signing_algorithm: str | None,
|
|
20
|
+
) -> bytes | str | None:
|
|
21
|
+
if signing_algorithm is None:
|
|
22
|
+
return signing_key if signing_key is not None else signing_private_key
|
|
23
|
+
normalized = signing_algorithm.strip().lower()
|
|
24
|
+
if normalized == "hmac-sha256":
|
|
25
|
+
return signing_key
|
|
26
|
+
if normalized == "ed25519":
|
|
27
|
+
return signing_private_key
|
|
28
|
+
raise ValueError(f"unsupported signing algorithm: {normalized}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_key_resolver(
|
|
32
|
+
*,
|
|
33
|
+
signing_key: bytes | str | None,
|
|
34
|
+
verifying_keys: dict[str, str] | None,
|
|
35
|
+
) -> Callable[[str | None], str | bytes | None] | dict[str, str] | None:
|
|
36
|
+
if verifying_keys:
|
|
37
|
+
return verifying_keys
|
|
38
|
+
if signing_key is None:
|
|
39
|
+
return None
|
|
40
|
+
return lambda _key_id: signing_key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _invoke_handler(handler: Callable[..., object], packet: TransportPacket) -> object:
|
|
44
|
+
parameters = list(inspect.signature(handler).parameters.values())
|
|
45
|
+
|
|
46
|
+
if len(parameters) == 1:
|
|
47
|
+
candidate = handler(packet)
|
|
48
|
+
elif len(parameters) == 2:
|
|
49
|
+
candidate = handler(packet.tenant.org_id, packet.payload)
|
|
50
|
+
elif len(parameters) == 0:
|
|
51
|
+
candidate = handler()
|
|
52
|
+
else:
|
|
53
|
+
candidate = handler(packet.tenant.org_id, packet.payload, packet)
|
|
54
|
+
|
|
55
|
+
if inspect.isawaitable(candidate):
|
|
56
|
+
return await candidate
|
|
57
|
+
return candidate
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_payload(result: object) -> dict[str, object]:
|
|
61
|
+
if isinstance(result, dict):
|
|
62
|
+
return result
|
|
63
|
+
raise TypeError(f"handler must return dict or TransportPacket, got {type(result)!r}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def execute_transport_packet(
|
|
67
|
+
packet: TransportPacket,
|
|
68
|
+
*,
|
|
69
|
+
node_name: str,
|
|
70
|
+
signing_key: bytes | str | None = None,
|
|
71
|
+
signing_private_key: str | None = None,
|
|
72
|
+
signing_key_id: str | None = None,
|
|
73
|
+
signing_algorithm: str | None = None,
|
|
74
|
+
verifying_keys: dict[str, str] | None = None,
|
|
75
|
+
require_signature: bool = False,
|
|
76
|
+
allowed_actions: tuple[str, ...] | None = None,
|
|
77
|
+
allowed_packet_types: tuple[str, ...] | None = None,
|
|
78
|
+
required_idempotency_actions: tuple[str, ...] | None = None,
|
|
79
|
+
replay_enabled: bool = True,
|
|
80
|
+
dev_mode: bool = False,
|
|
81
|
+
verify_hop_signatures: bool = False,
|
|
82
|
+
allowed_clock_skew_seconds: int = 30,
|
|
83
|
+
max_packet_bytes: int = 262_144,
|
|
84
|
+
max_hop_depth: int = 64,
|
|
85
|
+
max_delegation_depth: int = 8,
|
|
86
|
+
max_attachments: int = 32,
|
|
87
|
+
max_attachment_size_bytes: int = 10_485_760,
|
|
88
|
+
allowed_attachment_schemes: tuple[str, ...] = (),
|
|
89
|
+
allow_private_attachment_hosts: bool = False,
|
|
90
|
+
) -> TransportPacket:
|
|
91
|
+
normalized_node_name = node_name.strip().lower()
|
|
92
|
+
key_resolver = _build_key_resolver(
|
|
93
|
+
signing_key=signing_key,
|
|
94
|
+
verifying_keys=verifying_keys,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
validate_transport_packet(
|
|
98
|
+
packet,
|
|
99
|
+
key_resolver=key_resolver,
|
|
100
|
+
require_signature=require_signature,
|
|
101
|
+
max_packet_bytes=max_packet_bytes,
|
|
102
|
+
max_hop_depth=max_hop_depth,
|
|
103
|
+
max_delegation_depth=max_delegation_depth,
|
|
104
|
+
max_attachments=max_attachments,
|
|
105
|
+
max_attachment_size_bytes=max_attachment_size_bytes,
|
|
106
|
+
allowed_attachment_schemes=allowed_attachment_schemes,
|
|
107
|
+
allow_private_attachment_hosts=allow_private_attachment_hosts,
|
|
108
|
+
allowed_clock_skew_seconds=allowed_clock_skew_seconds,
|
|
109
|
+
local_node=normalized_node_name,
|
|
110
|
+
allowed_actions=allowed_actions,
|
|
111
|
+
allowed_packet_types=allowed_packet_types,
|
|
112
|
+
required_idempotency_actions=required_idempotency_actions,
|
|
113
|
+
replay_enabled=replay_enabled,
|
|
114
|
+
dev_mode=dev_mode,
|
|
115
|
+
verify_hop_signatures=verify_hop_signatures,
|
|
116
|
+
hop_key_resolver=key_resolver,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
handler = get_handler(packet.header.action)
|
|
120
|
+
if handler is None:
|
|
121
|
+
raise ValueError(f"no handler registered for action: {packet.header.action}")
|
|
122
|
+
|
|
123
|
+
processing_packet = packet.with_hop(
|
|
124
|
+
make_execution_hop(
|
|
125
|
+
packet=packet,
|
|
126
|
+
node=normalized_node_name,
|
|
127
|
+
action=packet.header.action,
|
|
128
|
+
status="processing",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
result = await asyncio.wait_for(
|
|
134
|
+
_invoke_handler(handler, processing_packet),
|
|
135
|
+
timeout=processing_packet.header.timeout_ms / 1000,
|
|
136
|
+
)
|
|
137
|
+
except TimeoutError as exc:
|
|
138
|
+
raise TimeoutError(
|
|
139
|
+
f"handler timeout after {processing_packet.header.timeout_ms}ms"
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
if isinstance(result, TransportPacket):
|
|
143
|
+
response_packet = result
|
|
144
|
+
else:
|
|
145
|
+
payload = _extract_payload(result)
|
|
146
|
+
response_packet = processing_packet.derive(
|
|
147
|
+
packet_type="response",
|
|
148
|
+
source_node=normalized_node_name,
|
|
149
|
+
destination_node=processing_packet.address.reply_to,
|
|
150
|
+
reply_to=normalized_node_name,
|
|
151
|
+
payload=payload,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
payload_status = str(response_packet.payload.get("status", "completed")).strip().lower()
|
|
155
|
+
if payload_status not in {
|
|
156
|
+
"received",
|
|
157
|
+
"validated",
|
|
158
|
+
"processing",
|
|
159
|
+
"delegated",
|
|
160
|
+
"completed",
|
|
161
|
+
"failed",
|
|
162
|
+
}:
|
|
163
|
+
payload_status = "completed"
|
|
164
|
+
|
|
165
|
+
response_packet = response_packet.with_hop(
|
|
166
|
+
make_response_hop(
|
|
167
|
+
packet=response_packet,
|
|
168
|
+
node=normalized_node_name,
|
|
169
|
+
action=response_packet.header.action,
|
|
170
|
+
status=payload_status,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
resolved_signing_key = _resolve_signing_material(
|
|
175
|
+
signing_key=signing_key,
|
|
176
|
+
signing_private_key=signing_private_key,
|
|
177
|
+
signing_algorithm=signing_algorithm,
|
|
178
|
+
)
|
|
179
|
+
if resolved_signing_key is not None:
|
|
180
|
+
if signing_key_id is None or signing_algorithm is None:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"signing_key_id and signing_algorithm are required when signing responses"
|
|
183
|
+
)
|
|
184
|
+
response_packet = sign_transport_packet(
|
|
185
|
+
response_packet,
|
|
186
|
+
key=resolved_signing_key,
|
|
187
|
+
key_id=signing_key_id,
|
|
188
|
+
algorithm=signing_algorithm,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return response_packet
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def create_error_transport_packet(
|
|
195
|
+
packet: TransportPacket,
|
|
196
|
+
error: Exception,
|
|
197
|
+
*,
|
|
198
|
+
node_name: str,
|
|
199
|
+
signing_key: bytes | str | None = None,
|
|
200
|
+
signing_private_key: str | None = None,
|
|
201
|
+
signing_key_id: str | None = None,
|
|
202
|
+
signing_algorithm: str | None = None,
|
|
203
|
+
expose_internal_errors: bool = False,
|
|
204
|
+
) -> TransportPacket:
|
|
205
|
+
normalized_node_name = node_name.strip().lower()
|
|
206
|
+
client_message = str(error) if expose_internal_errors else error.__class__.__name__
|
|
207
|
+
|
|
208
|
+
response_packet = packet.derive(
|
|
209
|
+
packet_type="failure",
|
|
210
|
+
source_node=normalized_node_name,
|
|
211
|
+
destination_node=packet.address.reply_to,
|
|
212
|
+
reply_to=normalized_node_name,
|
|
213
|
+
payload={
|
|
214
|
+
"status": "failed",
|
|
215
|
+
"error": error.__class__.__name__,
|
|
216
|
+
"message": client_message,
|
|
217
|
+
"packet_id": str(packet.header.packet_id),
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
response_packet = response_packet.with_hop(
|
|
222
|
+
make_response_hop(
|
|
223
|
+
packet=response_packet,
|
|
224
|
+
node=normalized_node_name,
|
|
225
|
+
action=response_packet.header.action,
|
|
226
|
+
status="failed",
|
|
227
|
+
error_code=error.__class__.__name__,
|
|
228
|
+
error_message=str(error) if expose_internal_errors else client_message,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
resolved_signing_key = _resolve_signing_material(
|
|
233
|
+
signing_key=signing_key,
|
|
234
|
+
signing_private_key=signing_private_key,
|
|
235
|
+
signing_algorithm=signing_algorithm,
|
|
236
|
+
)
|
|
237
|
+
if resolved_signing_key is not None:
|
|
238
|
+
if signing_key_id is None or signing_algorithm is None:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"signing_key_id and signing_algorithm are required when signing error responses"
|
|
241
|
+
)
|
|
242
|
+
response_packet = sign_transport_packet(
|
|
243
|
+
response_packet,
|
|
244
|
+
key=resolved_signing_key,
|
|
245
|
+
key_id=signing_key_id,
|
|
246
|
+
algorithm=signing_algorithm,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return response_packet
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
_HANDLER_REGISTRY: dict[str, Callable[..., Any]] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_handler(action: str, fn: Callable[..., Any] | None = None) -> Callable[..., Any]:
|
|
10
|
+
"""
|
|
11
|
+
Register a runtime handler for a transport action.
|
|
12
|
+
"""
|
|
13
|
+
normalized_action = action.strip().lower()
|
|
14
|
+
if not normalized_action:
|
|
15
|
+
raise ValueError("action must not be empty")
|
|
16
|
+
|
|
17
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
18
|
+
_HANDLER_REGISTRY[normalized_action] = func
|
|
19
|
+
return func
|
|
20
|
+
|
|
21
|
+
if fn is not None:
|
|
22
|
+
return decorator(fn)
|
|
23
|
+
return decorator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_handler(action: str) -> Callable[..., Any] | None:
|
|
27
|
+
"""
|
|
28
|
+
Resolve a handler by exact action, falling back to '*' if present.
|
|
29
|
+
"""
|
|
30
|
+
normalized_action = action.strip().lower()
|
|
31
|
+
return _HANDLER_REGISTRY.get(normalized_action) or _HANDLER_REGISTRY.get("*")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clear_handlers() -> None:
|
|
35
|
+
"""
|
|
36
|
+
Clear all registered handlers.
|
|
37
|
+
"""
|
|
38
|
+
_HANDLER_REGISTRY.clear()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def registered_actions() -> tuple[str, ...]:
|
|
42
|
+
"""
|
|
43
|
+
Return the registered action names in sorted order.
|
|
44
|
+
"""
|
|
45
|
+
return tuple(sorted(_HANDLER_REGISTRY.keys()))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_gate_source(packet: TransportPacket, *, gate_node_name: str = "gate") -> None:
|
|
7
|
+
expected = gate_node_name.strip().lower()
|
|
8
|
+
actual = packet.address.source_node.strip().lower()
|
|
9
|
+
if actual != expected:
|
|
10
|
+
raise ValueError(f"inbound packets must originate from {expected!r}, got {actual!r}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def assert_packet_targets_local_node(packet: TransportPacket, *, local_node: str) -> None:
|
|
14
|
+
expected = local_node.strip().lower()
|
|
15
|
+
actual = packet.address.destination_node.strip().lower()
|
|
16
|
+
if actual != expected:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"inbound packet destination_node {actual!r} does not match local node {expected!r}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assert_gate_mediated_provenance(
|
|
23
|
+
packet: TransportPacket,
|
|
24
|
+
*,
|
|
25
|
+
allow_route_kinds: tuple[str, ...],
|
|
26
|
+
require_route_kind: bool = True,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not packet.provenance.resolved_by_gate:
|
|
29
|
+
raise ValueError("inbound packet provenance.resolved_by_gate must be true")
|
|
30
|
+
|
|
31
|
+
if packet.provenance.origin_kind not in {"client", "node", "gate"}:
|
|
32
|
+
raise ValueError("invalid inbound provenance.origin_kind")
|
|
33
|
+
|
|
34
|
+
route_kind = packet.provenance.route_kind
|
|
35
|
+
if route_kind is None:
|
|
36
|
+
if require_route_kind:
|
|
37
|
+
raise ValueError("inbound packet provenance.route_kind is required")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
normalized_route_kind = route_kind.strip().lower()
|
|
41
|
+
normalized_allowed = tuple(value.strip().lower() for value in allow_route_kinds)
|
|
42
|
+
if normalized_route_kind not in normalized_allowed:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"inbound packet provenance.route_kind must be one of "
|
|
45
|
+
f"{normalized_allowed}, got {normalized_route_kind!r}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_execute_ingress_packet(
|
|
50
|
+
packet: TransportPacket,
|
|
51
|
+
*,
|
|
52
|
+
local_node: str,
|
|
53
|
+
gate_node_name: str = "gate",
|
|
54
|
+
require_route_kind: bool = True,
|
|
55
|
+
) -> None:
|
|
56
|
+
assert_gate_source(packet, gate_node_name=gate_node_name)
|
|
57
|
+
assert_packet_targets_local_node(packet, local_node=local_node)
|
|
58
|
+
assert_gate_mediated_provenance(
|
|
59
|
+
packet,
|
|
60
|
+
allow_route_kinds=("external_ingress",),
|
|
61
|
+
require_route_kind=require_route_kind,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_relay_ingress_packet(
|
|
66
|
+
packet: TransportPacket,
|
|
67
|
+
*,
|
|
68
|
+
local_node: str,
|
|
69
|
+
gate_node_name: str = "gate",
|
|
70
|
+
require_route_kind: bool = True,
|
|
71
|
+
allowed_actions: tuple[str, ...] | None = None,
|
|
72
|
+
allowed_packet_types: tuple[str, ...] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
assert_gate_source(packet, gate_node_name=gate_node_name)
|
|
75
|
+
assert_packet_targets_local_node(packet, local_node=local_node)
|
|
76
|
+
assert_gate_mediated_provenance(
|
|
77
|
+
packet,
|
|
78
|
+
allow_route_kinds=("gate_relay",),
|
|
79
|
+
require_route_kind=require_route_kind,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if allowed_actions and packet.header.action not in allowed_actions:
|
|
83
|
+
raise ValueError(f"relay action {packet.header.action!r} is not allowed")
|
|
84
|
+
|
|
85
|
+
if allowed_packet_types and packet.header.packet_type not in allowed_packet_types:
|
|
86
|
+
raise ValueError(f"relay packet_type {packet.header.packet_type!r} is not allowed")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LifecycleHook(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Minimal lifecycle interface for node startup and shutdown hooks.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def startup(self) -> None:
|
|
13
|
+
"""Run node-specific startup logic."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def shutdown(self) -> None:
|
|
17
|
+
"""Run node-specific shutdown logic."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoOpLifecycle(LifecycleHook):
|
|
21
|
+
"""
|
|
22
|
+
Default lifecycle hook when a node has no custom startup or shutdown behavior.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def startup(self) -> None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
async def shutdown(self) -> None:
|
|
29
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from prometheus_client import (
|
|
7
|
+
CONTENT_TYPE_LATEST,
|
|
8
|
+
CollectorRegistry,
|
|
9
|
+
Counter,
|
|
10
|
+
Gauge,
|
|
11
|
+
generate_latest,
|
|
12
|
+
)
|
|
13
|
+
from pythonjsonlogger import jsonlogger
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
|
|
16
|
+
from .config import NodeRuntimeConfig
|
|
17
|
+
|
|
18
|
+
REGISTRY = CollectorRegistry(auto_describe=True)
|
|
19
|
+
REQUESTS_TOTAL = Counter(
|
|
20
|
+
"constellation_node_requests_total",
|
|
21
|
+
"Total execute requests",
|
|
22
|
+
["service", "action", "status"],
|
|
23
|
+
registry=REGISTRY,
|
|
24
|
+
)
|
|
25
|
+
READY_GAUGE = Gauge(
|
|
26
|
+
"constellation_node_ready",
|
|
27
|
+
"Service readiness",
|
|
28
|
+
["service"],
|
|
29
|
+
registry=REGISTRY,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure_logging(config: NodeRuntimeConfig) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Configure root logging for the node runtime.
|
|
36
|
+
"""
|
|
37
|
+
root = logging.getLogger()
|
|
38
|
+
root.handlers.clear()
|
|
39
|
+
root.setLevel(logging.INFO)
|
|
40
|
+
|
|
41
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
42
|
+
handler.setFormatter(
|
|
43
|
+
jsonlogger.JsonFormatter( # type: ignore[attr-defined]
|
|
44
|
+
"%(asctime)s %(levelname)s %(name)s %(message)s"
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
root.addHandler(handler)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def set_readiness(*, config: NodeRuntimeConfig, ready: bool) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Update readiness gauge for the current service.
|
|
53
|
+
"""
|
|
54
|
+
READY_GAUGE.labels(service=config.service_name).set(1 if ready else 0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def record_request(*, config: NodeRuntimeConfig, action: str, status: str) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Increment request counter for an executed action.
|
|
60
|
+
"""
|
|
61
|
+
REQUESTS_TOTAL.labels(
|
|
62
|
+
service=config.service_name,
|
|
63
|
+
action=action.strip().lower(),
|
|
64
|
+
status=status.strip().lower(),
|
|
65
|
+
).inc()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def metrics_response() -> Response:
|
|
69
|
+
"""
|
|
70
|
+
Return the Prometheus metrics payload.
|
|
71
|
+
"""
|
|
72
|
+
return Response(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .config import NodeRuntimeConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PreflightFailure(RuntimeError):
|
|
7
|
+
"""Raised when runtime configuration is not safe to start."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_preflight(config: NodeRuntimeConfig) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Validate runtime configuration relationships before starting a node runtime.
|
|
13
|
+
"""
|
|
14
|
+
_validate_security_profile(config)
|
|
15
|
+
_validate_action_configuration(config)
|
|
16
|
+
_validate_attachment_configuration(config)
|
|
17
|
+
_validate_gate_configuration(config)
|
|
18
|
+
_validate_gate_ingress_authentication(config)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_security_profile(config: NodeRuntimeConfig) -> None:
|
|
22
|
+
if config.dev_mode:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
if not config.require_signature:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if config.signing_algorithm == "hmac-sha256":
|
|
29
|
+
if not config.signing_key:
|
|
30
|
+
raise PreflightFailure("hmac-sha256 requires signing_key when signatures are required")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if config.signing_algorithm == "ed25519":
|
|
34
|
+
if not config.signing_private_key:
|
|
35
|
+
raise PreflightFailure(
|
|
36
|
+
"ed25519 requires signing_private_key when signatures are required"
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
raise PreflightFailure(f"unsupported signing algorithm: {config.signing_algorithm}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_action_configuration(config: NodeRuntimeConfig) -> None:
|
|
44
|
+
allowed_actions = set(config.allowed_actions)
|
|
45
|
+
execute_allowed_actions = set(config.execute_allowed_actions)
|
|
46
|
+
known_actions = allowed_actions | execute_allowed_actions
|
|
47
|
+
required_idempotency = set(config.require_idempotency_for_actions)
|
|
48
|
+
|
|
49
|
+
if required_idempotency and not known_actions:
|
|
50
|
+
raise PreflightFailure(
|
|
51
|
+
"require_idempotency_for_actions is configured but allowed_actions and"
|
|
52
|
+
" execute_allowed_actions are both empty"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if not required_idempotency.issubset(known_actions):
|
|
56
|
+
invalid = sorted(required_idempotency - known_actions)
|
|
57
|
+
joined = ", ".join(invalid)
|
|
58
|
+
raise PreflightFailure(
|
|
59
|
+
"require_idempotency_for_actions contains actions not present"
|
|
60
|
+
f" in allowed_actions or execute_allowed_actions: {joined}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if "response" in allowed_actions or "failure" in allowed_actions:
|
|
64
|
+
raise PreflightFailure(
|
|
65
|
+
"allowed_actions must list executable inbound actions only, not response packet types"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_attachment_configuration(config: NodeRuntimeConfig) -> None:
|
|
70
|
+
if config.max_attachment_size_bytes > config.max_packet_bytes:
|
|
71
|
+
raise PreflightFailure("max_attachment_size_bytes must not exceed max_packet_bytes")
|
|
72
|
+
|
|
73
|
+
if config.max_attachments > 0 and not config.attachment_allowed_schemes:
|
|
74
|
+
raise PreflightFailure(
|
|
75
|
+
"attachment_allowed_schemes must be configured when attachments are enabled"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_gate_configuration(config: NodeRuntimeConfig) -> None:
|
|
80
|
+
if config.gate_url is None:
|
|
81
|
+
return
|
|
82
|
+
if not (config.gate_url.startswith("http://") or config.gate_url.startswith("https://")):
|
|
83
|
+
raise PreflightFailure("gate_url must start with http:// or https://")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _validate_gate_ingress_authentication(config: NodeRuntimeConfig) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Gate-only ingress trust (provenance.resolved_by_gate, address.source_node,
|
|
89
|
+
provenance.route_kind) is caller-supplied and carries no authentication by
|
|
90
|
+
itself. If enforce_gate_only_ingress is relied upon without also requiring
|
|
91
|
+
a verified transport signature, any direct client can forge these claims
|
|
92
|
+
and bypass Gate-mediated routing entirely. Fail closed outside dev_mode.
|
|
93
|
+
"""
|
|
94
|
+
if config.dev_mode or not config.enforce_gate_only_ingress:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
execute_require_signature = (
|
|
98
|
+
config.execute_require_signature
|
|
99
|
+
if config.execute_require_signature is not None
|
|
100
|
+
else config.require_signature
|
|
101
|
+
)
|
|
102
|
+
if not execute_require_signature:
|
|
103
|
+
raise PreflightFailure(
|
|
104
|
+
"enforce_gate_only_ingress=true requires a verified signature on /v1/execute "
|
|
105
|
+
"(set require_signature=true or execute_require_signature=true); "
|
|
106
|
+
"unsigned Gate-only ingress trusts unauthenticated caller-supplied provenance"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not config.enable_relay_route:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
relay_require_signature = (
|
|
113
|
+
config.relay_require_signature
|
|
114
|
+
if config.relay_require_signature is not None
|
|
115
|
+
else config.require_signature
|
|
116
|
+
)
|
|
117
|
+
if not relay_require_signature:
|
|
118
|
+
raise PreflightFailure(
|
|
119
|
+
"enforce_gate_only_ingress=true requires a verified signature on /v1/relay "
|
|
120
|
+
"(set require_signature=true or relay_require_signature=true); "
|
|
121
|
+
"unsigned Gate-only ingress trusts unauthenticated caller-supplied provenance"
|
|
122
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.security.delegation import compute_delegation_proof
|
|
4
|
+
from constellation_node_sdk.security.errors import (
|
|
5
|
+
SecurityError,
|
|
6
|
+
TransportAuthenticationError,
|
|
7
|
+
TransportAuthorizationError,
|
|
8
|
+
TransportExpiredError,
|
|
9
|
+
TransportIntegrityError,
|
|
10
|
+
TransportNotYetValidError,
|
|
11
|
+
TransportValidationError,
|
|
12
|
+
)
|
|
13
|
+
from constellation_node_sdk.security.signing import recompute_transport_core, sign_transport_packet
|
|
14
|
+
from constellation_node_sdk.security.validation import (
|
|
15
|
+
transport_packet_size_bytes,
|
|
16
|
+
validate_derived_transport_packet,
|
|
17
|
+
validate_transport_packet,
|
|
18
|
+
)
|
|
19
|
+
from constellation_node_sdk.security.verification import verify_transport_packet_signature
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SecurityError",
|
|
23
|
+
"TransportAuthenticationError",
|
|
24
|
+
"TransportAuthorizationError",
|
|
25
|
+
"TransportExpiredError",
|
|
26
|
+
"TransportIntegrityError",
|
|
27
|
+
"TransportNotYetValidError",
|
|
28
|
+
"TransportValidationError",
|
|
29
|
+
"compute_delegation_proof",
|
|
30
|
+
"recompute_transport_core",
|
|
31
|
+
"sign_transport_packet",
|
|
32
|
+
"transport_packet_size_bytes",
|
|
33
|
+
"validate_derived_transport_packet",
|
|
34
|
+
"validate_transport_packet",
|
|
35
|
+
"verify_transport_packet_signature",
|
|
36
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from constellation_node_sdk.transport.hashing import canonical_json
|
|
10
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _coerce_bytes(value: str | bytes) -> bytes:
|
|
14
|
+
if isinstance(value, bytes):
|
|
15
|
+
return value
|
|
16
|
+
normalized = value.strip()
|
|
17
|
+
try:
|
|
18
|
+
return base64.b64decode(normalized, validate=True)
|
|
19
|
+
except Exception:
|
|
20
|
+
return normalized.encode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_delegation_proof(
|
|
24
|
+
*,
|
|
25
|
+
packet: TransportPacket,
|
|
26
|
+
delegator: str,
|
|
27
|
+
delegatee: str,
|
|
28
|
+
scope: tuple[str, ...],
|
|
29
|
+
granted_at: datetime,
|
|
30
|
+
key: bytes | str,
|
|
31
|
+
expires_at: datetime | None = None,
|
|
32
|
+
constraints: dict[str, Any] | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Compute an HMAC proof for a delegation grant.
|
|
36
|
+
|
|
37
|
+
Delegation proofs are bound to:
|
|
38
|
+
- packet_id
|
|
39
|
+
- transport_hash
|
|
40
|
+
- delegator/delegatee
|
|
41
|
+
- scope
|
|
42
|
+
- grant time
|
|
43
|
+
- optional expiry/constraints
|
|
44
|
+
"""
|
|
45
|
+
raw_key = _coerce_bytes(key)
|
|
46
|
+
normalized_scope = tuple(item.strip().lower() for item in scope if item.strip())
|
|
47
|
+
message = {
|
|
48
|
+
"packet_id": str(packet.header.packet_id),
|
|
49
|
+
"transport_hash": packet.security.transport_hash,
|
|
50
|
+
"delegator": delegator.strip().lower(),
|
|
51
|
+
"delegatee": delegatee.strip().lower(),
|
|
52
|
+
"scope": list(normalized_scope),
|
|
53
|
+
"granted_at": granted_at.astimezone(UTC).isoformat(),
|
|
54
|
+
"expires_at": expires_at.astimezone(UTC).isoformat() if expires_at else None,
|
|
55
|
+
"constraints": constraints or {},
|
|
56
|
+
}
|
|
57
|
+
return hmac.new(raw_key, canonical_json(message), hashlib.sha256).hexdigest()
|