fleetmesh-node 0.2.0__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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/transport/router.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""The router: TransportRouter, the ladder from direct rungs down to the gift-wrap rung.
|
|
2
|
+
|
|
3
|
+
Separate from the rungs because it is the only module that knows about all of them,
|
|
4
|
+
and the direct rungs import from const.py without ever importing the router back.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import (
|
|
15
|
+
Any,
|
|
16
|
+
Callable,
|
|
17
|
+
Counter,
|
|
18
|
+
Deque,
|
|
19
|
+
Mapping,
|
|
20
|
+
MutableMapping,
|
|
21
|
+
Optional,
|
|
22
|
+
Sequence,
|
|
23
|
+
TypedDict,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from fleetmesh_core.identity.did import did_nostr
|
|
27
|
+
from fleetmesh_core.identity import didcomm
|
|
28
|
+
from . import didcomm_inbound, fips_rung
|
|
29
|
+
from fleetmesh_core.identity import iroh_ticket
|
|
30
|
+
from . import ladder as transport_ladder
|
|
31
|
+
from fleetmesh_core.storage import replication
|
|
32
|
+
from . import const
|
|
33
|
+
from .const import Inbound
|
|
34
|
+
from .fips_rung import FipsRungMixin
|
|
35
|
+
from . import rung as rung_mod
|
|
36
|
+
from ..interfaces import DIDCommMessage, RelayHealthRow
|
|
37
|
+
from .relay_rung import RelayRung as NostrTransport
|
|
38
|
+
from .relay_rung.cooldown import RelayCooldown
|
|
39
|
+
from .relay_rung.types import AckVerdict, AwaitingEntry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RefusalRow(TypedDict):
|
|
43
|
+
"""A direct rung's refusal of one peer, for the ladder's telemetry."""
|
|
44
|
+
peer: str
|
|
45
|
+
transport: str
|
|
46
|
+
error: str
|
|
47
|
+
at: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LadderHealthRow(TypedDict):
|
|
51
|
+
"""Circuit breaker and availability status of one transport rung."""
|
|
52
|
+
available: bool
|
|
53
|
+
circuit_breakers: dict[str, str]
|
|
54
|
+
failures: dict[str, int]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
log = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NullTransport:
|
|
61
|
+
"""A null transport rung used when Nostr is disabled in configuration."""
|
|
62
|
+
name: str = 'none'
|
|
63
|
+
health: Mapping[str, Any] = {}
|
|
64
|
+
awaiting: Mapping[str, Any] = {}
|
|
65
|
+
acked: Mapping[str, int] = {}
|
|
66
|
+
acks_in: int = 0
|
|
67
|
+
acks_out: int = 0
|
|
68
|
+
unacked: Sequence[Any] = ()
|
|
69
|
+
wraps_in: Mapping[str, int] = {}
|
|
70
|
+
wraps_seen_again: Mapping[str, int] = {}
|
|
71
|
+
cooldown: Optional[Any] = None
|
|
72
|
+
|
|
73
|
+
def listening(self) -> tuple[str, ...]:
|
|
74
|
+
return ()
|
|
75
|
+
|
|
76
|
+
def wait_listening(self, timeout: float = 0) -> bool:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def start(self, since: int = 0) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def stop(self) -> None:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def unfinished(self) -> list[Any]:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
def sweep(self, now: int) -> list[Any]:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
def take_unacked(self) -> list[Any]:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
def listen(self, thid: str) -> None:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def unlisten(self, thid: str) -> None:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def available(self) -> bool:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def send(self, *args: Any, **kwargs: Any) -> Mapping[str, Any]:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TransportRouter(FipsRungMixin):
|
|
108
|
+
"""The multi-transport router and ladder negotiator.
|
|
109
|
+
|
|
110
|
+
Implements TransportProtocol across all active transports:
|
|
111
|
+
- fips-native: datagram flows through the fips sidecar (mesh-encrypted)
|
|
112
|
+
- iroh: EXPERIMENTAL / unauthenticated. Direct TCP stream or the
|
|
113
|
+
iroh_live UDP framing; the wire itself is neither encrypted nor
|
|
114
|
+
authenticated, only the DIDComm JWE inside is. Opt-in: built only
|
|
115
|
+
when config names an iroh endpoint (see __init__).
|
|
116
|
+
- libp2p: stream through the p2p sidecar, when the node builds one
|
|
117
|
+
- nostr: NIP-59 gift-wrapped DIDComm over relays (Kinds 21059 / 1059)
|
|
118
|
+
|
|
119
|
+
The ladder in send() ranks fips-native, iroh, libp2p, nostr by
|
|
120
|
+
preference; the rank says which is tried first, not which is safer.
|
|
121
|
+
Every rung is held to the same envelope rule (didcomm_inbound).
|
|
122
|
+
|
|
123
|
+
Performs dynamic ladder negotiation (fleetmesh_core.storage.replication.negotiate) based on
|
|
124
|
+
peer descriptor endpoints, automatically falling back down the ladder to
|
|
125
|
+
Nostr when direct streams fail or are unavailable.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
scheme: str = 'router'
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
keystore: Any,
|
|
133
|
+
peers: Any,
|
|
134
|
+
relay_urls: Sequence[str] = (),
|
|
135
|
+
endpoints: Optional[Sequence[Any]] = None,
|
|
136
|
+
clock: Optional[Callable[[], int]] = None,
|
|
137
|
+
timeout: float = 15.0,
|
|
138
|
+
seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = None,
|
|
139
|
+
ack_store: Optional[Any] = None,
|
|
140
|
+
iroh_host: str = '127.0.0.1',
|
|
141
|
+
iroh_port: int = 0,
|
|
142
|
+
sidecar_dir: Optional[str] = None,
|
|
143
|
+
enable_iroh: Optional[bool] = None,
|
|
144
|
+
iroh_mode: str = 'stream',
|
|
145
|
+
fips_sock_path: Optional[str] = None,
|
|
146
|
+
enable_fips: bool = True,
|
|
147
|
+
fips_port: int = 11801,
|
|
148
|
+
fips_client: Optional[Any] = None,
|
|
149
|
+
enable_nostr: bool = True,
|
|
150
|
+
) -> None:
|
|
151
|
+
self.ks: Any = keystore
|
|
152
|
+
self.peers: Any = peers
|
|
153
|
+
self.relays: list[str] = list(relay_urls)
|
|
154
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
155
|
+
self.timeout: float = timeout
|
|
156
|
+
self.endpoints_cfg: list[Any] = list(endpoints or ())
|
|
157
|
+
self.enable_nostr: bool = enable_nostr
|
|
158
|
+
|
|
159
|
+
if enable_nostr:
|
|
160
|
+
self.nostr: Any = NostrTransport(
|
|
161
|
+
keystore=keystore,
|
|
162
|
+
peers=peers,
|
|
163
|
+
relay_urls=relay_urls,
|
|
164
|
+
clock=clock,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
seen_store=seen_store,
|
|
167
|
+
ack_store=ack_store,
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
self.nostr = NullTransport()
|
|
171
|
+
|
|
172
|
+
# Check for configured iroh endpoint
|
|
173
|
+
iroh_ep = None
|
|
174
|
+
for ep in self.endpoints_cfg:
|
|
175
|
+
if isinstance(ep, dict) and ep.get('transport') == 'iroh':
|
|
176
|
+
iroh_ep = ep
|
|
177
|
+
break
|
|
178
|
+
elif isinstance(ep, (tuple, list)) and len(ep) >= 2 and ep[0] == 'iroh':
|
|
179
|
+
iroh_ep = {'transport': 'iroh', 'address': ep[1], 'priority': ep[2] if len(ep) > 2 else 10}
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
self.iroh: Optional[Any] = None
|
|
183
|
+
# The iroh rung is EXPERIMENTAL and opt-in. Its framing (TCP stream
|
|
184
|
+
# or the iroh_live UDP engine) is neither encrypted nor authenticated
|
|
185
|
+
# at the transport layer: only the DIDComm JWE inside protects the
|
|
186
|
+
# message. So the rung is built only when the operator names an iroh
|
|
187
|
+
# endpoint in config (or a caller passes enable_iroh=True); a node
|
|
188
|
+
# with no such endpoint binds no iroh listener and never dials iroh.
|
|
189
|
+
if enable_iroh is None:
|
|
190
|
+
enable_iroh = iroh_ep is not None
|
|
191
|
+
if enable_iroh:
|
|
192
|
+
from .iroh_rung import IrohTransport
|
|
193
|
+
specified_ticket = iroh_ep.get('address') or iroh_ep.get('ticket') if iroh_ep else None
|
|
194
|
+
h = iroh_host
|
|
195
|
+
p = iroh_port
|
|
196
|
+
if specified_ticket:
|
|
197
|
+
try:
|
|
198
|
+
t_parsed = iroh_ticket.IrohTicket.parse(specified_ticket)
|
|
199
|
+
h, p = t_parsed.host, t_parsed.port
|
|
200
|
+
except ValueError as e:
|
|
201
|
+
# An empty or non-numeric-port ticket: the configured
|
|
202
|
+
# host/port stand, and the ticket string is still handed
|
|
203
|
+
# to the transport as the advertised address.
|
|
204
|
+
log.warning('iroh: configured ticket unparseable, using host/port: %s', e)
|
|
205
|
+
eff_mode = (iroh_ep.get('mode') if iroh_ep else None) or iroh_mode
|
|
206
|
+
self.iroh = IrohTransport(
|
|
207
|
+
keystore=keystore,
|
|
208
|
+
peers=peers,
|
|
209
|
+
host=h,
|
|
210
|
+
port=p,
|
|
211
|
+
ticket=specified_ticket,
|
|
212
|
+
sidecar_dir=sidecar_dir,
|
|
213
|
+
clock=clock,
|
|
214
|
+
timeout=min(timeout, 5.0),
|
|
215
|
+
mode=eff_mode,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Check for configured fips endpoint
|
|
219
|
+
fips_ep = None
|
|
220
|
+
for ep in self.endpoints_cfg:
|
|
221
|
+
if isinstance(ep, dict) and ep.get('transport') in ('fips', 'fips-native'):
|
|
222
|
+
fips_ep = ep
|
|
223
|
+
break
|
|
224
|
+
elif isinstance(ep, (tuple, list)) and len(ep) >= 2 and ep[0] in ('fips', 'fips-native'):
|
|
225
|
+
fips_ep = {'transport': ep[0], 'address': ep[1], 'priority': ep[2] if len(ep) > 2 else 5}
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
self.fips: Optional[Any] = fips_client
|
|
229
|
+
self.fips_port: int = fips_port
|
|
230
|
+
self.fips_sock_path: Optional[str] = fips_sock_path or (fips_ep.get('sock_path') if fips_ep else None)
|
|
231
|
+
self._fips_listener_sock: Optional[Any] = None
|
|
232
|
+
self._fips_thread: Optional[threading.Thread] = None
|
|
233
|
+
|
|
234
|
+
if enable_fips and self.fips is None:
|
|
235
|
+
sock_p = self.fips_sock_path or fips_rung.find_api_socket()
|
|
236
|
+
if sock_p and os.path.exists(sock_p):
|
|
237
|
+
try:
|
|
238
|
+
self.fips = fips_rung.FipsNativeTransport(sock_path=sock_p, node_pk=self.ks.node_pk)
|
|
239
|
+
except Exception:
|
|
240
|
+
self.fips = None
|
|
241
|
+
|
|
242
|
+
# The libp2p rung is a compartment (node/p2p_wire.P2PTransport) that
|
|
243
|
+
# registers itself here when the node builds it; a router without one
|
|
244
|
+
# simply has no rung to try.
|
|
245
|
+
self.p2p: Optional[Any] = None
|
|
246
|
+
|
|
247
|
+
self.transports: MutableMapping[str, Any] = {'nostr': self.nostr} if self.enable_nostr else {}
|
|
248
|
+
if self.iroh is not None:
|
|
249
|
+
self.transports['iroh'] = self.iroh
|
|
250
|
+
if self.fips is not None:
|
|
251
|
+
self.transports['fips-native'] = self.fips
|
|
252
|
+
|
|
253
|
+
# Unified inbox and thread routing: directly shared with underlying transports
|
|
254
|
+
self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=const.INBOX_MAX)
|
|
255
|
+
self._waiting: dict[str, queue.Queue[Inbound]] = {}
|
|
256
|
+
self._lock: threading.Lock = threading.Lock()
|
|
257
|
+
|
|
258
|
+
# Connect both transports directly to this router's inbox and waiter map
|
|
259
|
+
self.nostr.inbox = self.inbox
|
|
260
|
+
self.nostr._waiting = self._waiting
|
|
261
|
+
if self.iroh is not None:
|
|
262
|
+
self.iroh.inbox = self.inbox
|
|
263
|
+
self.iroh._waiting = self._waiting
|
|
264
|
+
|
|
265
|
+
# Telemetry metrics
|
|
266
|
+
self.success_counts: Counter[str] = collections.Counter()
|
|
267
|
+
self.refusals_log: Deque[RefusalRow] = collections.deque(maxlen=const.DROPPED_KEPT)
|
|
268
|
+
# What the ladder itself refused: a direct-rung payload that would
|
|
269
|
+
# not open, a rung that failed and was cascaded past. Each rung keeps
|
|
270
|
+
# its own; faults() reads them all.
|
|
271
|
+
self.faults: transport_ladder.FaultLog = transport_ladder.FaultLog('router', clock=self.clock,
|
|
272
|
+
keep=const.DROPPED_KEPT)
|
|
273
|
+
# Replay and freshness for the direct rungs that deliver through
|
|
274
|
+
# deliver_direct (fips-native, libp2p). The iroh rung holds its own.
|
|
275
|
+
self.guard: didcomm_inbound.InboundGuard = didcomm_inbound.InboundGuard()
|
|
276
|
+
self.circuit_breakers: dict[str, transport_ladder.CircuitBreaker] = {
|
|
277
|
+
'fips-native': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
|
|
278
|
+
'iroh': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
|
|
279
|
+
'libp2p': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
|
|
280
|
+
'nostr': transport_ladder.CircuitBreaker(failure_threshold=5, recovery_timeout=15.0),
|
|
281
|
+
}
|
|
282
|
+
# The ladder, top to floor. Each rung knows how to reach a peer and
|
|
283
|
+
# how to deliver once; send() walks them and keeps the books. The
|
|
284
|
+
# relay rung is the floor and is always last.
|
|
285
|
+
seal: rung_mod.Seal = lambda m, pk, anon: self._seal_direct(m, pk, self.peers.agreement(pk), anon)
|
|
286
|
+
self.rungs: list[rung_mod.RungProtocol] = [
|
|
287
|
+
rung_mod.FipsRung(self, seal),
|
|
288
|
+
rung_mod.IrohRung(self),
|
|
289
|
+
rung_mod.Libp2pRung(self, seal),
|
|
290
|
+
]
|
|
291
|
+
if self.enable_nostr:
|
|
292
|
+
self.rungs.append(rung_mod.RelayRungAdapter(self))
|
|
293
|
+
self._send_interceptor: Optional[Callable[..., dict[str, tuple[bool, str]]]] = None
|
|
294
|
+
self._rung_interceptor: Optional[Callable[[str, DIDCommMessage | Mapping[str, Any], str, Any, bool], Optional[dict[str, tuple[bool, str]]]]] = None
|
|
295
|
+
|
|
296
|
+
def register_rung(self, rung: rung_mod.RungProtocol) -> None:
|
|
297
|
+
"""Register a dynamic transport rung into the ladder."""
|
|
298
|
+
if not hasattr(rung, 'name') or not hasattr(rung, 'deliver'):
|
|
299
|
+
raise TypeError(f"Object {rung!r} does not conform to RungProtocol")
|
|
300
|
+
# Replace existing rung if already present, else append
|
|
301
|
+
existing_idx = next((i for i, r in enumerate(self.rungs) if r.name == rung.name), None)
|
|
302
|
+
if existing_idx is not None:
|
|
303
|
+
self.rungs[existing_idx] = rung
|
|
304
|
+
else:
|
|
305
|
+
self.rungs.append(rung)
|
|
306
|
+
# Keep ladder sorted by preference rank (lower rank is tried first)
|
|
307
|
+
self.rungs.sort(key=lambda r: getattr(r, 'rank', 50))
|
|
308
|
+
if rung.name not in self.circuit_breakers:
|
|
309
|
+
self.circuit_breakers[rung.name] = transport_ladder.CircuitBreaker(
|
|
310
|
+
failure_threshold=3, recovery_timeout=30.0
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def add_rung(self, rung: rung_mod.RungProtocol) -> None:
|
|
314
|
+
"""Alias for register_rung to satisfy ProtocolManager."""
|
|
315
|
+
self.register_rung(rung)
|
|
316
|
+
|
|
317
|
+
def unregister_rung(self, name: str) -> Optional[rung_mod.RungProtocol]:
|
|
318
|
+
"""Remove a transport rung by name from the ladder."""
|
|
319
|
+
removed: Optional[rung_mod.RungProtocol] = None
|
|
320
|
+
new_rungs = []
|
|
321
|
+
for r in self.rungs:
|
|
322
|
+
if r.name == name:
|
|
323
|
+
removed = r
|
|
324
|
+
else:
|
|
325
|
+
new_rungs.append(r)
|
|
326
|
+
self.rungs = new_rungs
|
|
327
|
+
return removed
|
|
328
|
+
|
|
329
|
+
def get_rung(self, name: str) -> Optional[rung_mod.RungProtocol]:
|
|
330
|
+
"""Retrieve a registered transport rung by name."""
|
|
331
|
+
return next((r for r in self.rungs if r.name == name), None)
|
|
332
|
+
|
|
333
|
+
# ---- What the ladder shows the compartments: the relay rung's books
|
|
334
|
+
# by name (it is the store-and-forward floor), every rung's health and
|
|
335
|
+
# faults through the ladder. Nothing resolves by accident.
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def health(self) -> Mapping[str, RelayHealthRow]:
|
|
339
|
+
# Expose relay health map expected by Link subsystem
|
|
340
|
+
h: Mapping[str, RelayHealthRow] = dict(self.nostr.health)
|
|
341
|
+
return h
|
|
342
|
+
|
|
343
|
+
def rung_health(self) -> dict[str, Mapping[str, int | str | bool | float]]:
|
|
344
|
+
"""Every rung's own report, keyed by rung name; a rung whose daemon
|
|
345
|
+
is not built is absent. The one view of the ladder's state."""
|
|
346
|
+
out: dict[str, Mapping[str, int | str | bool | float]] = {}
|
|
347
|
+
for rung in self.rungs:
|
|
348
|
+
h = rung.health()
|
|
349
|
+
if h is not None:
|
|
350
|
+
out[rung.name] = h
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def ladder_health(self) -> dict[str, LadderHealthRow]:
|
|
355
|
+
"""Circuit breaker and availability status across transport rungs."""
|
|
356
|
+
return {
|
|
357
|
+
name: {
|
|
358
|
+
'available': name in self.transports or (name == 'libp2p' and self._p2p_ready()),
|
|
359
|
+
'circuit_breakers': dict(cb._states),
|
|
360
|
+
'failures': dict(cb._failures),
|
|
361
|
+
}
|
|
362
|
+
for name, cb in self.circuit_breakers.items()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def _p2p_ready(self) -> bool:
|
|
366
|
+
return self.p2p is not None and bool(getattr(self.p2p, 'connected', False))
|
|
367
|
+
|
|
368
|
+
def fault_logs(self) -> list[transport_ladder.FaultLog]:
|
|
369
|
+
"""The ladder's own fault log and every rung's that keeps one."""
|
|
370
|
+
logs = [self.faults]
|
|
371
|
+
for rung in self.rungs:
|
|
372
|
+
log = rung.fault_log()
|
|
373
|
+
if log is not None:
|
|
374
|
+
logs.append(log)
|
|
375
|
+
return logs
|
|
376
|
+
|
|
377
|
+
def recent_faults(self, since: Optional[int] = None) -> list[transport_ladder.Fault]:
|
|
378
|
+
"""What every rung refused or lost, newest last, with reasons."""
|
|
379
|
+
return transport_ladder.merge_faults(self.fault_logs(), since)
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def drop_counts(self) -> Counter[str]:
|
|
383
|
+
c: Counter[str] = collections.Counter()
|
|
384
|
+
for lg in self.fault_logs():
|
|
385
|
+
c.update(lg.counts)
|
|
386
|
+
return c
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def dropped(self) -> Deque[tuple[str, Optional[str]]]:
|
|
390
|
+
return collections.deque(
|
|
391
|
+
((f['reason'], f['ident']) for f in self.recent_faults()), maxlen=const.DROPPED_KEPT)
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def cooldown(self) -> Optional[RelayCooldown]:
|
|
395
|
+
"""The relay rung's cooldown ledger: which relays are being rested
|
|
396
|
+
after a rate-limit and until when. None for a relay-rung double
|
|
397
|
+
that keeps none."""
|
|
398
|
+
cd = getattr(self.nostr, 'cooldown', None)
|
|
399
|
+
return cd if isinstance(cd, RelayCooldown) else None
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def awaiting(self) -> dict[str, AwaitingEntry]:
|
|
403
|
+
return self.nostr.awaiting
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def acked(self) -> Counter[str]:
|
|
407
|
+
return self.nostr.acked
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def acks_in(self) -> int:
|
|
411
|
+
return self.nostr.acks_in
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def acks_out(self) -> int:
|
|
415
|
+
return self.nostr.acks_out
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def unacked(self) -> Deque[AckVerdict]:
|
|
419
|
+
return self.nostr.unacked
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def wraps_in(self) -> Counter[Optional[int]]:
|
|
423
|
+
return self.nostr.wraps_in
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def wraps_seen_again(self) -> Counter[Optional[int]]:
|
|
427
|
+
return self.nostr.wraps_seen_again
|
|
428
|
+
|
|
429
|
+
def unfinished(self) -> int:
|
|
430
|
+
return self.nostr.unfinished()
|
|
431
|
+
|
|
432
|
+
def sweep(self, now: Optional[int] = None) -> list[AckVerdict]:
|
|
433
|
+
return self.nostr.sweep(now)
|
|
434
|
+
|
|
435
|
+
def take_unacked(self) -> list[AckVerdict]:
|
|
436
|
+
return self.nostr.take_unacked()
|
|
437
|
+
|
|
438
|
+
def listening(self) -> int:
|
|
439
|
+
base = self.nostr.listening()
|
|
440
|
+
if self.iroh is not None and getattr(self.iroh, '_server_sock', None) is not None:
|
|
441
|
+
base += 1
|
|
442
|
+
if self._fips_listener_sock is not None:
|
|
443
|
+
base += 1
|
|
444
|
+
return base
|
|
445
|
+
|
|
446
|
+
def wait_listening(self, timeout: float = 8.0) -> bool:
|
|
447
|
+
return self.nostr.wait_listening(timeout)
|
|
448
|
+
|
|
449
|
+
# ---- Lifecycle ---------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def start(self, since: Optional[int] = None) -> TransportRouter:
|
|
452
|
+
self.nostr.start(since=since)
|
|
453
|
+
if self.iroh is not None:
|
|
454
|
+
self.iroh.start()
|
|
455
|
+
if self.fips is not None and self.fips.is_available():
|
|
456
|
+
try:
|
|
457
|
+
self.fips.connect()
|
|
458
|
+
self._fips_listener_sock = self.fips.listen_port(self.fips_port)
|
|
459
|
+
self._fips_thread = threading.Thread(target=self._listen_fips, daemon=True)
|
|
460
|
+
self._fips_thread.start()
|
|
461
|
+
except (OSError, fips_rung.FipsTransportError) as e:
|
|
462
|
+
# The daemon answered is_available() but would not give us a
|
|
463
|
+
# listener: the rung stays built but inert, and the ladder
|
|
464
|
+
# falls through to the next rung. Said once, loudly.
|
|
465
|
+
log.warning('fips-native: listener not started, rung inert: %s', e)
|
|
466
|
+
return self
|
|
467
|
+
|
|
468
|
+
def deliver_direct(
|
|
469
|
+
self,
|
|
470
|
+
scheme: str,
|
|
471
|
+
payload: Any,
|
|
472
|
+
wrap_id: str,
|
|
473
|
+
origin: Optional[str] = None,
|
|
474
|
+
) -> Optional[str]:
|
|
475
|
+
"""One payload off a direct rung (fips-native, libp2p): a JWE, or nothing.
|
|
476
|
+
|
|
477
|
+
Returns None when the message was queued or was an empty ping, else
|
|
478
|
+
the reason it was dropped, which is also recorded.
|
|
479
|
+
|
|
480
|
+
The daemon behind a direct rung proves for itself who the other end
|
|
481
|
+
of a flow or stream is, but this node never sees that proof - it
|
|
482
|
+
reads a payload off a local socket, and on a cluster daemon, a mock,
|
|
483
|
+
or a shared host anyone with the socket can write one. So the payload
|
|
484
|
+
is treated exactly as a stream off the internet is: it must be a JWE
|
|
485
|
+
this node can open, and the sender is whoever the agreement proves,
|
|
486
|
+
never the `from` field. A plaintext frame on this path used to arrive
|
|
487
|
+
as an authenticated message from anyone it cared to name, and
|
|
488
|
+
resolved whatever request() was waiting for; now it is a drop with a
|
|
489
|
+
reason, and nothing else.
|
|
490
|
+
|
|
491
|
+
`origin`, when a sidecar reports one, is the raw Ed25519 transport
|
|
492
|
+
key hex the remote stream authenticated with. It is a hint, not the
|
|
493
|
+
sender: the sender's descriptor publishes its transport key, and a
|
|
494
|
+
message whose proven sender advertises a different one did not come
|
|
495
|
+
from that sender's node, so it is dropped rather than delivered as
|
|
496
|
+
something it is not.
|
|
497
|
+
|
|
498
|
+
Nothing in here may raise: the reader loop ends on any exception,
|
|
499
|
+
which would let one malformed frame take the flow down.
|
|
500
|
+
"""
|
|
501
|
+
if not payload:
|
|
502
|
+
return None
|
|
503
|
+
try:
|
|
504
|
+
inbound, why = didcomm_inbound.open_inbound(payload, self.ks, self.peers, wrap_id,
|
|
505
|
+
guard=self.guard, now=self.clock())
|
|
506
|
+
except Exception as e: # belt and braces: open_inbound is meant not to raise
|
|
507
|
+
inbound, why = None, 'envelope: %s: %s' % (type(e).__name__, e)
|
|
508
|
+
if inbound is None:
|
|
509
|
+
reason = '%s: %s' % (scheme, why or 'refused')
|
|
510
|
+
self.faults.record(reason, wrap_id)
|
|
511
|
+
return reason
|
|
512
|
+
if not inbound.authenticated and not const.anonymous_allowed(inbound.message.get('type')):
|
|
513
|
+
# Anoncrypt off a direct rung: no skid and no seal, so the sender
|
|
514
|
+
# is a claim. Only a type whose handler treats the sender as a
|
|
515
|
+
# stranger (const.ANONYMOUS_OK) is delivered; everything else is
|
|
516
|
+
# refused here, before any handler can read inbound.sender.
|
|
517
|
+
reason = '%s: unauthenticated: anoncrypt %s is not a first-contact type' % (
|
|
518
|
+
scheme, str(inbound.message.get('type'))[:80])
|
|
519
|
+
self.refusals_log.append(RefusalRow(peer=inbound.sender, transport=scheme, error='unauthenticated %s' % str(inbound.message.get('type'))[:80], at=self.clock()))
|
|
520
|
+
self.faults.record(reason, wrap_id)
|
|
521
|
+
return reason
|
|
522
|
+
if origin is not None and inbound.authenticated:
|
|
523
|
+
try:
|
|
524
|
+
from ..planes.transport.p2p import compartment as p2p_wire
|
|
525
|
+
published = p2p_wire.transport_key_of(self.peers.descriptor(inbound.sender))
|
|
526
|
+
except Exception:
|
|
527
|
+
published = None
|
|
528
|
+
if published is not None and published.hex() != origin:
|
|
529
|
+
reason = '%s: stream from transport key %s, but sender %s publishes %s' % (
|
|
530
|
+
scheme, origin[:8], inbound.sender[:8], published.hex()[:8])
|
|
531
|
+
self.faults.record(reason, wrap_id)
|
|
532
|
+
return reason
|
|
533
|
+
if inbound.message.get('type') == const.EMPTY_TYPE:
|
|
534
|
+
return None
|
|
535
|
+
thid = inbound.thid
|
|
536
|
+
with self._lock:
|
|
537
|
+
waiter = self._waiting.get(thid) if thid else None
|
|
538
|
+
try:
|
|
539
|
+
(waiter if waiter is not None else self.inbox).put_nowait(inbound)
|
|
540
|
+
except queue.Full:
|
|
541
|
+
# Queue saturated; drop the frame rather than block the reader.
|
|
542
|
+
reason = '%s: inbox full' % scheme
|
|
543
|
+
self.faults.record(reason, wrap_id)
|
|
544
|
+
return reason
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
def stop(self) -> None:
|
|
548
|
+
if self._fips_listener_sock is not None:
|
|
549
|
+
try:
|
|
550
|
+
self._fips_listener_sock.close()
|
|
551
|
+
except OSError:
|
|
552
|
+
pass # best-effort close on shutdown
|
|
553
|
+
self._fips_listener_sock = None
|
|
554
|
+
if self.fips is not None:
|
|
555
|
+
try:
|
|
556
|
+
self.fips.close()
|
|
557
|
+
except (OSError, fips_rung.FipsTransportError):
|
|
558
|
+
pass # best-effort close on shutdown; the daemon may be gone already
|
|
559
|
+
if self.iroh is not None:
|
|
560
|
+
self.iroh.stop()
|
|
561
|
+
self.nostr.stop()
|
|
562
|
+
|
|
563
|
+
# ---- Dispatch & Ladder Negotiation ------------------------------------
|
|
564
|
+
|
|
565
|
+
def set_send_interceptor(
|
|
566
|
+
self,
|
|
567
|
+
interceptor: Optional[Callable[..., dict[str, tuple[bool, str]]]],
|
|
568
|
+
) -> None:
|
|
569
|
+
"""Register a send interceptor (e.g. for cluster partition and loss simulation)."""
|
|
570
|
+
self._send_interceptor = interceptor
|
|
571
|
+
|
|
572
|
+
def clear_send_interceptor(self) -> None:
|
|
573
|
+
"""Remove any registered send interceptor."""
|
|
574
|
+
self._send_interceptor = None
|
|
575
|
+
|
|
576
|
+
def set_rung_interceptor(
|
|
577
|
+
self,
|
|
578
|
+
interceptor: Optional[Callable[[str, DIDCommMessage | Mapping[str, Any], str, Any, bool], Optional[dict[str, tuple[bool, str]]]]],
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Register a per-rung delivery interceptor (e.g. for directed partition and cascade testing)."""
|
|
581
|
+
self._rung_interceptor = interceptor
|
|
582
|
+
|
|
583
|
+
def clear_rung_interceptor(self) -> None:
|
|
584
|
+
"""Remove any registered rung interceptor."""
|
|
585
|
+
self._rung_interceptor = None
|
|
586
|
+
|
|
587
|
+
def send(
|
|
588
|
+
self,
|
|
589
|
+
message: DIDCommMessage | Mapping[str, Any],
|
|
590
|
+
to_pubkey: str,
|
|
591
|
+
anonymous: bool = False,
|
|
592
|
+
stored: bool = False,
|
|
593
|
+
) -> dict[str, tuple[bool, str]]:
|
|
594
|
+
"""Dispatch a message over the top negotiated transport ladder rung.
|
|
595
|
+
|
|
596
|
+
`stored=True` requires store-and-forward relays (NIP-59 Kind 1059).
|
|
597
|
+
Live messages negotiate `fips-native` or `iroh` if mutually supported,
|
|
598
|
+
falling back down the ladder to relay gift-wraps if dialing fails.
|
|
599
|
+
"""
|
|
600
|
+
if self._send_interceptor is not None:
|
|
601
|
+
return self._send_interceptor(message, to_pubkey, anonymous=anonymous, stored=stored)
|
|
602
|
+
return self._send_ladder(message, to_pubkey, anonymous=anonymous, stored=stored)
|
|
603
|
+
|
|
604
|
+
def _send_ladder(
|
|
605
|
+
self,
|
|
606
|
+
message: DIDCommMessage | Mapping[str, Any],
|
|
607
|
+
to_pubkey: str,
|
|
608
|
+
anonymous: bool = False,
|
|
609
|
+
stored: bool = False,
|
|
610
|
+
) -> dict[str, tuple[bool, str]]:
|
|
611
|
+
if stored:
|
|
612
|
+
nostr_rung = getattr(self, 'nostr', None)
|
|
613
|
+
if nostr_rung is not None and hasattr(nostr_rung, 'send') and getattr(nostr_rung, 'available', lambda: True)():
|
|
614
|
+
res = self.nostr.send(message, to_pubkey, anonymous=anonymous, stored=True)
|
|
615
|
+
if any(v[0] for v in res.values()):
|
|
616
|
+
self.success_counts['nostr'] += 1
|
|
617
|
+
return res
|
|
618
|
+
return {'router': (False, 'no store-and-forward transport available')}
|
|
619
|
+
|
|
620
|
+
desc = self.peers.descriptor(to_pubkey)
|
|
621
|
+
theirs, _ = rung_mod.endpoints_of(desc)
|
|
622
|
+
mine = {r.name: r.rank for r in self.rungs if r.available()}
|
|
623
|
+
if self.enable_nostr:
|
|
624
|
+
mine['wss'] = 40
|
|
625
|
+
order = replication.negotiate(mine, theirs) if theirs else [r.name for r in self.rungs]
|
|
626
|
+
by_name = {r.name: r for r in self.rungs}
|
|
627
|
+
|
|
628
|
+
# The walk. Each rung in negotiated order that is up, that the peer
|
|
629
|
+
# advertises, and whose breaker allows the peer gets one attempt;
|
|
630
|
+
# a failure is this rung's fault and the next rung's turn. The
|
|
631
|
+
# relay rung is appended if available and negotiation left it out.
|
|
632
|
+
nostr_rung = by_name.get('nostr')
|
|
633
|
+
if nostr_rung is not None and nostr_rung.available() and 'nostr' not in order:
|
|
634
|
+
order.append('nostr')
|
|
635
|
+
last: rung_mod.Verdict = {}
|
|
636
|
+
for name in order:
|
|
637
|
+
rung = by_name.get(name)
|
|
638
|
+
if rung is None or not rung.available():
|
|
639
|
+
continue
|
|
640
|
+
target = rung.target_of(desc, theirs)
|
|
641
|
+
if target is None:
|
|
642
|
+
continue
|
|
643
|
+
cb = self.circuit_breakers.get(name)
|
|
644
|
+
if cb is not None and not cb.allow_attempt(to_pubkey, now=self.clock()):
|
|
645
|
+
continue
|
|
646
|
+
try:
|
|
647
|
+
if self._rung_interceptor is not None:
|
|
648
|
+
inter_res = self._rung_interceptor(name, message, to_pubkey, target, anonymous)
|
|
649
|
+
if inter_res is not None:
|
|
650
|
+
res = inter_res
|
|
651
|
+
else:
|
|
652
|
+
res = rung.deliver(message, to_pubkey, target, anonymous)
|
|
653
|
+
else:
|
|
654
|
+
res = rung.deliver(message, to_pubkey, target, anonymous)
|
|
655
|
+
except Exception as e:
|
|
656
|
+
if cb is not None:
|
|
657
|
+
cb.record_failure(to_pubkey, now=self.clock())
|
|
658
|
+
self.refusals_log.append(RefusalRow(peer=to_pubkey, transport=name, error=str(e), at=self.clock()))
|
|
659
|
+
self.faults.record('%s failed, cascading down the ladder: %s' % (name, e), None)
|
|
660
|
+
continue
|
|
661
|
+
if any(v[0] for v in res.values()):
|
|
662
|
+
self.success_counts[name] += 1
|
|
663
|
+
if cb is not None:
|
|
664
|
+
cb.record_success(to_pubkey)
|
|
665
|
+
if name == 'nostr' or any(v[0] for v in res.values()):
|
|
666
|
+
return res
|
|
667
|
+
last = res
|
|
668
|
+
return last
|
|
669
|
+
|
|
670
|
+
def _seal_direct(
|
|
671
|
+
self,
|
|
672
|
+
message: DIDCommMessage | Mapping[str, Any],
|
|
673
|
+
to_pubkey: str,
|
|
674
|
+
their_agree: Any,
|
|
675
|
+
anonymous: bool,
|
|
676
|
+
) -> Mapping[str, Any]:
|
|
677
|
+
"""The envelope every direct rung sends: the same JWE the relays carry.
|
|
678
|
+
|
|
679
|
+
A direct path is one its daemon authenticates for itself, but the
|
|
680
|
+
node's proof of who wrote a message is the ECDH-1PU agreement, and
|
|
681
|
+
the receiving side (deliver_direct) accepts nothing else.
|
|
682
|
+
"""
|
|
683
|
+
msg_dict = dict(message)
|
|
684
|
+
msg_dict.setdefault('from', self.ks.did)
|
|
685
|
+
msg_dict.setdefault('to', [did_nostr(to_pubkey)])
|
|
686
|
+
msg_dict.setdefault('created_time', self.clock())
|
|
687
|
+
kid = did_nostr(to_pubkey) + '#agree'
|
|
688
|
+
sender = None if anonymous else (self.ks.kid('agreement'), self.ks.agreement_priv)
|
|
689
|
+
jwe = didcomm.encrypt(msg_dict, [(kid, their_agree)], sender=sender)
|
|
690
|
+
return jwe
|
|
691
|
+
|
|
692
|
+
def request(
|
|
693
|
+
self,
|
|
694
|
+
message: DIDCommMessage | Mapping[str, Any],
|
|
695
|
+
to_pubkey: str,
|
|
696
|
+
timeout: float = 30.0,
|
|
697
|
+
anonymous: bool = False,
|
|
698
|
+
) -> Optional[Inbound]:
|
|
699
|
+
"""Send and wait for the reply threaded on this message's id."""
|
|
700
|
+
return self.converse(message, to_pubkey, message['id'], timeout, anonymous)
|
|
701
|
+
|
|
702
|
+
def converse(
|
|
703
|
+
self,
|
|
704
|
+
message: DIDCommMessage | Mapping[str, Any],
|
|
705
|
+
to_pubkey: str,
|
|
706
|
+
thid: str,
|
|
707
|
+
timeout: float = 30.0,
|
|
708
|
+
anonymous: bool = False,
|
|
709
|
+
) -> Optional[Inbound]:
|
|
710
|
+
"""Send and wait for next message on thread across any active transport."""
|
|
711
|
+
q = self.listen(thid)
|
|
712
|
+
try:
|
|
713
|
+
self.send(message, to_pubkey, anonymous=anonymous, stored=False)
|
|
714
|
+
try:
|
|
715
|
+
return q.get(timeout=timeout)
|
|
716
|
+
except queue.Empty:
|
|
717
|
+
return None
|
|
718
|
+
finally:
|
|
719
|
+
self.unlisten(thid)
|
|
720
|
+
|
|
721
|
+
def listen(self, thid: str) -> queue.Queue[Inbound]:
|
|
722
|
+
"""Route messages on this thread to a dedicated queue."""
|
|
723
|
+
with self._lock:
|
|
724
|
+
q = self._waiting.get(thid)
|
|
725
|
+
if q is None:
|
|
726
|
+
q = self._waiting[thid] = queue.Queue()
|
|
727
|
+
self.nostr.listen(thid)
|
|
728
|
+
if self.iroh is not None:
|
|
729
|
+
self.iroh.listen(thid)
|
|
730
|
+
return q
|
|
731
|
+
|
|
732
|
+
def unlisten(self, thid: str) -> None:
|
|
733
|
+
with self._lock:
|
|
734
|
+
self._waiting.pop(thid, None)
|
|
735
|
+
self.nostr.unlisten(thid)
|
|
736
|
+
if self.iroh is not None:
|
|
737
|
+
self.iroh.unlisten(thid)
|
|
738
|
+
|
|
739
|
+
def receive(self, timeout: Optional[float] = None) -> Optional[Inbound]:
|
|
740
|
+
"""Fetch next delivered message from unified inbox."""
|
|
741
|
+
try:
|
|
742
|
+
return self.inbox.get(timeout=timeout)
|
|
743
|
+
except queue.Empty:
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
def metric_tags(self) -> list[list[str]]:
|
|
747
|
+
"""Telemetry metric tags per spec section 6444."""
|
|
748
|
+
tag = [
|
|
749
|
+
'metric',
|
|
750
|
+
'transport_success',
|
|
751
|
+
f"iroh:{self.success_counts['iroh']}",
|
|
752
|
+
f"nostr:{self.success_counts['nostr']}",
|
|
753
|
+
]
|
|
754
|
+
if self.success_counts['libp2p'] > 0 or self._p2p_ready():
|
|
755
|
+
tag.append(f"libp2p:{self.success_counts['libp2p']}")
|
|
756
|
+
if self.success_counts['fips-native'] > 0 or self.fips is not None:
|
|
757
|
+
tag.append(f"fips-native:{self.success_counts['fips-native']}")
|
|
758
|
+
for name, count in self.success_counts.items():
|
|
759
|
+
if name not in ('iroh', 'nostr', 'libp2p', 'fips-native') and count > 0:
|
|
760
|
+
tag.append(f"{name}:{count}")
|
|
761
|
+
return [tag]
|