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
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Iroh QUIC / stream transport adapter for fleetmesh.
|
|
2
|
+
|
|
3
|
+
Protocol ALPN: `fleetmesh/didcomm/1` as specified in `spec.html` and `schema/messages.json`.
|
|
4
|
+
Provides direct, low-latency, point-to-point DIDComm streaming between mesh nodes
|
|
5
|
+
using Iroh NodeAddr tickets and Ed25519 transport identities.
|
|
6
|
+
|
|
7
|
+
Operates in two modes:
|
|
8
|
+
1. Pure Python In-Process Stream Server & Client (zero external C-dependencies):
|
|
9
|
+
Standard-library socket streaming with 4-byte big-endian framing over ALPN
|
|
10
|
+
`fleetmesh/didcomm/1`, supporting instant bi-directional request/reply and
|
|
11
|
+
direct peer-to-peer delivery.
|
|
12
|
+
2. Iroh Sidecar Adapter Mode (`iroh.sock`):
|
|
13
|
+
When an external Rust `iroh` daemon is running alongside the node,
|
|
14
|
+
communicates via local UNIX domain socket to leverage full UDP hole-punching
|
|
15
|
+
and DERP relay routing across NATs.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import collections
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import queue
|
|
24
|
+
import socket
|
|
25
|
+
import struct
|
|
26
|
+
import tempfile
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import (
|
|
30
|
+
Any,
|
|
31
|
+
Callable,
|
|
32
|
+
Counter,
|
|
33
|
+
Deque,
|
|
34
|
+
Mapping,
|
|
35
|
+
Optional,
|
|
36
|
+
Union,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from fleetmesh_core.identity.did import did_nostr
|
|
40
|
+
from fleetmesh_core.crypto import keys
|
|
41
|
+
from fleetmesh_core.identity import didcomm
|
|
42
|
+
from .. import didcomm_inbound
|
|
43
|
+
from .. import const as transport_const
|
|
44
|
+
from ..const import Inbound, TransportError
|
|
45
|
+
from fleetmesh_core.identity.iroh_ticket import ALPN, IrohTicket # re-exported: the ticket is the rung's shared leaf
|
|
46
|
+
from .types import IrohHealthReport
|
|
47
|
+
|
|
48
|
+
DEFAULT_PREFERENCE: int = 10
|
|
49
|
+
MAX_STREAM_PAYLOAD: int = 16 * 1024 * 1024 # 16 MB maximum stream envelope
|
|
50
|
+
SOCK: str = 'iroh.sock'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sidecar_socket_path(directory: str) -> str:
|
|
54
|
+
"""Derive unix socket path for the iroh sidecar."""
|
|
55
|
+
real = os.path.realpath(directory)
|
|
56
|
+
p = os.path.join(real, SOCK)
|
|
57
|
+
if len(p.encode('utf-8')) < 100:
|
|
58
|
+
return p
|
|
59
|
+
return os.path.join(
|
|
60
|
+
tempfile.gettempdir(),
|
|
61
|
+
'fleetmesh-iroh-%s.sock' % hashlib.sha256(real.encode('utf-8')).hexdigest()[:16]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class IrohTransport:
|
|
66
|
+
"""Iroh QUIC / stream transport adapter.
|
|
67
|
+
|
|
68
|
+
Manages ALPN fleetmesh/didcomm/1 listeners and outbound stream dispatch.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
scheme: str = 'iroh'
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
keystore: Any,
|
|
76
|
+
peers: Any,
|
|
77
|
+
host: str = '127.0.0.1',
|
|
78
|
+
port: int = 0,
|
|
79
|
+
ticket: Optional[str] = None,
|
|
80
|
+
sidecar_dir: Optional[str] = None,
|
|
81
|
+
clock: Optional[Callable[[], int]] = None,
|
|
82
|
+
timeout: float = 5.0,
|
|
83
|
+
mode: str = 'stream',
|
|
84
|
+
) -> None:
|
|
85
|
+
self.ks: Any = keystore
|
|
86
|
+
self.peers: Any = peers
|
|
87
|
+
self.host: str = host
|
|
88
|
+
self.port: int = port
|
|
89
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
90
|
+
self.timeout: float = timeout
|
|
91
|
+
self.sidecar_dir: Optional[str] = sidecar_dir
|
|
92
|
+
self.mode: str = mode
|
|
93
|
+
|
|
94
|
+
# Node Ed25519 transport public key (Multikey fed01...)
|
|
95
|
+
raw_transport = keys.raw_public(self.ks.transport_pub)
|
|
96
|
+
self.node_id: str = raw_transport.hex()
|
|
97
|
+
|
|
98
|
+
self._ticket_str: Optional[str] = ticket
|
|
99
|
+
self._server_sock: Optional[socket.socket] = None
|
|
100
|
+
self._listener_thread: Optional[threading.Thread] = None
|
|
101
|
+
self._stop: threading.Event = threading.Event()
|
|
102
|
+
|
|
103
|
+
self._quic_engine: Optional[Any] = None
|
|
104
|
+
self._quic_worker_thread: Optional[threading.Thread] = None
|
|
105
|
+
|
|
106
|
+
self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=10000)
|
|
107
|
+
self._waiting: dict[str, queue.Queue[Inbound]] = {} # thid -> Queue
|
|
108
|
+
self._lock: threading.Lock = threading.Lock()
|
|
109
|
+
|
|
110
|
+
# Telemetry & Health counters
|
|
111
|
+
self.streams_in: int = 0
|
|
112
|
+
self.streams_out: int = 0
|
|
113
|
+
self.bytes_in: int = 0
|
|
114
|
+
self.bytes_out: int = 0
|
|
115
|
+
self.dropped: Deque[tuple[str, Optional[str]]] = collections.deque(maxlen=200)
|
|
116
|
+
self.drop_counts: Counter[str] = collections.Counter()
|
|
117
|
+
# Replay and freshness state for this rung (didcomm_inbound.InboundGuard).
|
|
118
|
+
self.guard: didcomm_inbound.InboundGuard = didcomm_inbound.InboundGuard()
|
|
119
|
+
# Anoncrypt messages refused because their type is not first-contact.
|
|
120
|
+
self.anonymous_refused: int = 0
|
|
121
|
+
self.last_error: Optional[str] = None
|
|
122
|
+
self.active_sidecar: bool = False
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def ticket(self) -> str:
|
|
126
|
+
"""The canonical ticket string for advertising in Kind 11801."""
|
|
127
|
+
if self._ticket_str:
|
|
128
|
+
return self._ticket_str
|
|
129
|
+
return IrohTicket(self.node_id, self.host, self.port).encode()
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def health(self) -> IrohHealthReport:
|
|
133
|
+
"""Health status for operator telemetry and health check commands."""
|
|
134
|
+
is_conn = (
|
|
135
|
+
self._server_sock is not None
|
|
136
|
+
or self.active_sidecar
|
|
137
|
+
or (self._quic_engine is not None and self._quic_engine.sock is not None)
|
|
138
|
+
)
|
|
139
|
+
is_listen = (
|
|
140
|
+
self._server_sock is not None
|
|
141
|
+
or (self._quic_engine is not None and self._quic_engine.sock is not None)
|
|
142
|
+
)
|
|
143
|
+
h: IrohHealthReport = {
|
|
144
|
+
'scheme': 'iroh',
|
|
145
|
+
'alpn': ALPN,
|
|
146
|
+
'mode': self.mode,
|
|
147
|
+
'connected': is_conn,
|
|
148
|
+
'listening': is_listen,
|
|
149
|
+
'endpoint': f"{self.host}:{self.port}",
|
|
150
|
+
'ticket': self.ticket,
|
|
151
|
+
'node_id': self.node_id,
|
|
152
|
+
'streams_in': self.streams_in,
|
|
153
|
+
'streams_out': self.streams_out,
|
|
154
|
+
'bytes_in': self.bytes_in,
|
|
155
|
+
'bytes_out': self.bytes_out,
|
|
156
|
+
'last_error': self.last_error,
|
|
157
|
+
'active_sidecar': self.active_sidecar,
|
|
158
|
+
}
|
|
159
|
+
if self._quic_engine is not None:
|
|
160
|
+
h['quic_engine'] = self._quic_engine.status()
|
|
161
|
+
return h
|
|
162
|
+
|
|
163
|
+
def _drop(self, reason: str, ident: Optional[str] = None) -> None:
|
|
164
|
+
prefix = reason.split(':', 1)[0].strip()
|
|
165
|
+
self.drop_counts[prefix] += 1
|
|
166
|
+
self.dropped.append((reason, ident))
|
|
167
|
+
self.last_error = reason
|
|
168
|
+
|
|
169
|
+
def start(self) -> IrohTransport:
|
|
170
|
+
"""Bind server socket and launch stream receiver."""
|
|
171
|
+
# 1. QUIC Mode (RFC 9000 Live Engine over UDP)
|
|
172
|
+
if self.mode == 'quic':
|
|
173
|
+
from . import quic as iroh_live
|
|
174
|
+
if self._quic_engine is None:
|
|
175
|
+
self._quic_engine = iroh_live.IrohLiveEngine(
|
|
176
|
+
node_id=self.node_id,
|
|
177
|
+
host=self.host,
|
|
178
|
+
port=self.port,
|
|
179
|
+
alpn=ALPN,
|
|
180
|
+
timeout=self.timeout,
|
|
181
|
+
)
|
|
182
|
+
self._quic_engine.start()
|
|
183
|
+
self.port = self._quic_engine.port
|
|
184
|
+
self._stop.clear()
|
|
185
|
+
self._quic_worker_thread = threading.Thread(target=self._quic_worker_loop, daemon=True)
|
|
186
|
+
self._quic_worker_thread.start()
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
# 2. Check if external sidecar exists
|
|
190
|
+
if self.sidecar_dir:
|
|
191
|
+
sp = sidecar_socket_path(self.sidecar_dir)
|
|
192
|
+
if os.path.exists(sp):
|
|
193
|
+
from node.compartment.sidecar import probe_unix_socket
|
|
194
|
+
resp = probe_unix_socket(sp, {'cmd': 'status'}, timeout=1.0)
|
|
195
|
+
if isinstance(resp, dict) and resp.get('ok'):
|
|
196
|
+
self.active_sidecar = True
|
|
197
|
+
return self
|
|
198
|
+
self.active_sidecar = False
|
|
199
|
+
|
|
200
|
+
# 3. In-Process Socket Server (zero external dependencies)
|
|
201
|
+
if self._server_sock is None:
|
|
202
|
+
self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
203
|
+
self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
204
|
+
self._server_sock.bind((self.host, self.port))
|
|
205
|
+
# Resolve actual bound ephemeral port if port 0 was passed
|
|
206
|
+
self.port = self._server_sock.getsockname()[1]
|
|
207
|
+
self._server_sock.listen(64)
|
|
208
|
+
self._server_sock.settimeout(0.5)
|
|
209
|
+
|
|
210
|
+
self._stop.clear()
|
|
211
|
+
self._listener_thread = threading.Thread(target=self._accept_loop, daemon=True)
|
|
212
|
+
self._listener_thread.start()
|
|
213
|
+
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def _quic_worker_loop(self) -> None:
|
|
217
|
+
while not self._stop.is_set():
|
|
218
|
+
if self._quic_engine is None:
|
|
219
|
+
break
|
|
220
|
+
item = self._quic_engine.receive_stream(timeout=0.2)
|
|
221
|
+
if item is None:
|
|
222
|
+
continue
|
|
223
|
+
conn, stream_id, raw_bytes = item
|
|
224
|
+
self.streams_in += 1
|
|
225
|
+
self.bytes_in += len(raw_bytes)
|
|
226
|
+
|
|
227
|
+
inbound = self._process_inbound_bytes(raw_bytes)
|
|
228
|
+
if inbound is None:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
thid = inbound.thid
|
|
232
|
+
with self._lock:
|
|
233
|
+
q = self._waiting.get(thid) if thid else None
|
|
234
|
+
|
|
235
|
+
if q is not None:
|
|
236
|
+
try:
|
|
237
|
+
q.put_nowait(inbound)
|
|
238
|
+
except queue.Full:
|
|
239
|
+
self._drop('waiter queue full')
|
|
240
|
+
else:
|
|
241
|
+
try:
|
|
242
|
+
self.inbox.put_nowait(inbound)
|
|
243
|
+
except queue.Full:
|
|
244
|
+
self._drop('inbox queue saturated')
|
|
245
|
+
|
|
246
|
+
def stop(self) -> None:
|
|
247
|
+
"""Shut down listener socket and background threads."""
|
|
248
|
+
self._stop.set()
|
|
249
|
+
if self._quic_engine is not None:
|
|
250
|
+
self._quic_engine.stop()
|
|
251
|
+
self._quic_engine = None
|
|
252
|
+
if self._quic_worker_thread is not None:
|
|
253
|
+
self._quic_worker_thread.join(timeout=1.0)
|
|
254
|
+
self._quic_worker_thread = None
|
|
255
|
+
if self._server_sock is not None:
|
|
256
|
+
try:
|
|
257
|
+
self._server_sock.close()
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|
|
260
|
+
self._server_sock = None
|
|
261
|
+
if self._listener_thread is not None:
|
|
262
|
+
self._listener_thread.join(timeout=2.0)
|
|
263
|
+
self._listener_thread = None
|
|
264
|
+
|
|
265
|
+
def _accept_loop(self) -> None:
|
|
266
|
+
while not self._stop.is_set():
|
|
267
|
+
try:
|
|
268
|
+
if self._server_sock is None:
|
|
269
|
+
break
|
|
270
|
+
conn, addr = self._server_sock.accept()
|
|
271
|
+
except socket.timeout:
|
|
272
|
+
continue
|
|
273
|
+
except OSError:
|
|
274
|
+
break
|
|
275
|
+
threading.Thread(target=self._handle_conn, args=(conn, addr), daemon=True).start()
|
|
276
|
+
|
|
277
|
+
def _handle_conn(self, conn: socket.socket, addr: tuple[str, int]) -> None:
|
|
278
|
+
conn.settimeout(self.timeout)
|
|
279
|
+
try:
|
|
280
|
+
# Read 4-byte big-endian payload length
|
|
281
|
+
hdr = conn.recv(4)
|
|
282
|
+
if len(hdr) < 4:
|
|
283
|
+
return
|
|
284
|
+
length = struct.unpack('>I', hdr)[0]
|
|
285
|
+
if length > MAX_STREAM_PAYLOAD:
|
|
286
|
+
self._drop('stream payload exceeds maximum length limit')
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
buf = bytearray()
|
|
290
|
+
while len(buf) < length:
|
|
291
|
+
chunk = conn.recv(min(65536, length - len(buf)))
|
|
292
|
+
if not chunk:
|
|
293
|
+
break
|
|
294
|
+
buf.extend(chunk)
|
|
295
|
+
|
|
296
|
+
if len(buf) < length:
|
|
297
|
+
self._drop('incomplete stream payload received')
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
self.streams_in += 1
|
|
301
|
+
self.bytes_in += len(buf) + 4
|
|
302
|
+
|
|
303
|
+
# Process inbound payload
|
|
304
|
+
inbound = self._process_inbound_bytes(bytes(buf))
|
|
305
|
+
if inbound is None:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
thid = inbound.thid
|
|
309
|
+
with self._lock:
|
|
310
|
+
q = self._waiting.get(thid) if thid else None
|
|
311
|
+
|
|
312
|
+
if q is not None:
|
|
313
|
+
try:
|
|
314
|
+
q.put_nowait(inbound)
|
|
315
|
+
except queue.Full:
|
|
316
|
+
self._drop('waiter queue full')
|
|
317
|
+
else:
|
|
318
|
+
try:
|
|
319
|
+
self.inbox.put_nowait(inbound)
|
|
320
|
+
except queue.Full:
|
|
321
|
+
self._drop('inbox queue saturated')
|
|
322
|
+
|
|
323
|
+
except Exception as e:
|
|
324
|
+
self._drop(f"stream read failed: {e}")
|
|
325
|
+
finally:
|
|
326
|
+
try:
|
|
327
|
+
conn.close()
|
|
328
|
+
except OSError:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
def _process_inbound_bytes(self, raw_bytes: bytes) -> Optional[Inbound]:
|
|
332
|
+
"""Open a DIDComm JWE off a stream; None, with the reason recorded.
|
|
333
|
+
|
|
334
|
+
A stream is bytes from whoever connected to the port. There used to
|
|
335
|
+
be a plaintext fallback here "for discovery / ping" that built an
|
|
336
|
+
Inbound out of an unencrypted object and named as its sender whatever
|
|
337
|
+
`from` said. Unauthenticated, yes, but delivered into the same
|
|
338
|
+
`_waiting` queue that request() reads by thid, so a connect-and-write
|
|
339
|
+
from anyone answered any conversation this node had open. Discovery
|
|
340
|
+
has its own protocols (mDNS, descriptors); the DIDComm port carries
|
|
341
|
+
envelopes and refuses the rest.
|
|
342
|
+
"""
|
|
343
|
+
inbound, why = didcomm_inbound.open_inbound(
|
|
344
|
+
raw_bytes, self.ks, self.peers, f"iroh-{os.urandom(8).hex()}",
|
|
345
|
+
guard=self.guard, now=int(self.clock()))
|
|
346
|
+
if inbound is None:
|
|
347
|
+
self._drop(why or 'envelope: refused')
|
|
348
|
+
return None
|
|
349
|
+
if not inbound.authenticated and not transport_const.anonymous_allowed(inbound.message.get('type')):
|
|
350
|
+
# Anoncrypt off a stream: nobody proved who wrote it, and no
|
|
351
|
+
# handler for this type treats the sender as a stranger.
|
|
352
|
+
self.anonymous_refused += 1
|
|
353
|
+
self._drop('unauthenticated: anoncrypt %s is not a first-contact type'
|
|
354
|
+
% str(inbound.message.get('type'))[:80])
|
|
355
|
+
return None
|
|
356
|
+
return inbound
|
|
357
|
+
|
|
358
|
+
# ---- Outbound Stream Dispatch ------------------------------------------
|
|
359
|
+
|
|
360
|
+
def send(
|
|
361
|
+
self,
|
|
362
|
+
message: Mapping[str, Any],
|
|
363
|
+
to_pubkey: str,
|
|
364
|
+
ticket: Optional[Union[str, IrohTicket]] = None,
|
|
365
|
+
anonymous: bool = False,
|
|
366
|
+
timeout: Optional[float] = None,
|
|
367
|
+
) -> dict[str, tuple[bool, str]]:
|
|
368
|
+
"""Encrypt and dispatch a DIDComm envelope over an Iroh stream.
|
|
369
|
+
|
|
370
|
+
Returns { 'iroh': (True, 'delivered via iroh quic stream') }.
|
|
371
|
+
"""
|
|
372
|
+
parsed_ticket = self._resolve_ticket(to_pubkey, ticket)
|
|
373
|
+
if parsed_ticket is None:
|
|
374
|
+
raise TransportError(f"no iroh ticket found for peer {to_pubkey[:16]}")
|
|
375
|
+
|
|
376
|
+
their_agree = self.peers.agreement(to_pubkey)
|
|
377
|
+
if their_agree is None:
|
|
378
|
+
raise TransportError(f"no live agreement key for peer {to_pubkey[:16]}")
|
|
379
|
+
|
|
380
|
+
msg_dict = dict(message)
|
|
381
|
+
msg_dict.setdefault('from', self.ks.did)
|
|
382
|
+
msg_dict.setdefault('to', [did_nostr(to_pubkey)])
|
|
383
|
+
msg_dict.setdefault('created_time', int(self.clock()))
|
|
384
|
+
|
|
385
|
+
kid = did_nostr(to_pubkey) + '#agree'
|
|
386
|
+
sender = None if anonymous else (self.ks.kid('agreement'), self.ks.agreement_priv)
|
|
387
|
+
jwe = didcomm.encrypt(msg_dict, [(kid, their_agree)], sender=sender)
|
|
388
|
+
|
|
389
|
+
payload_bytes = json.dumps(jwe, separators=(',', ':'), sort_keys=True).encode('utf-8')
|
|
390
|
+
|
|
391
|
+
# QUIC Mode (RFC 9000 Live Engine over UDP)
|
|
392
|
+
if self.mode == 'quic':
|
|
393
|
+
if self._quic_engine is None:
|
|
394
|
+
raise TransportError("quic engine not running")
|
|
395
|
+
conn = self._quic_engine.get_or_connect(parsed_ticket)
|
|
396
|
+
sid = self._quic_engine.send_stream(conn, payload_bytes)
|
|
397
|
+
self.streams_out += 1
|
|
398
|
+
self.bytes_out += len(payload_bytes)
|
|
399
|
+
return {'iroh': (True, f"delivered via iroh quic stream {sid} to {parsed_ticket.host}:{parsed_ticket.port}")}
|
|
400
|
+
|
|
401
|
+
framed = struct.pack('>I', len(payload_bytes)) + payload_bytes
|
|
402
|
+
|
|
403
|
+
eff_timeout = timeout or self.timeout
|
|
404
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
405
|
+
sock.settimeout(eff_timeout)
|
|
406
|
+
try:
|
|
407
|
+
sock.connect((parsed_ticket.host, parsed_ticket.port))
|
|
408
|
+
sock.sendall(framed)
|
|
409
|
+
self.streams_out += 1
|
|
410
|
+
self.bytes_out += len(framed)
|
|
411
|
+
return {'iroh': (True, f"delivered via iroh quic stream to {parsed_ticket.host}:{parsed_ticket.port}")}
|
|
412
|
+
except Exception as e:
|
|
413
|
+
self._drop(f"iroh stream connect failed: {e}", to_pubkey)
|
|
414
|
+
raise TransportError(f"iroh stream connect to {parsed_ticket.host}:{parsed_ticket.port} failed: {e}")
|
|
415
|
+
finally:
|
|
416
|
+
try:
|
|
417
|
+
sock.close()
|
|
418
|
+
except OSError:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
def request(
|
|
422
|
+
self,
|
|
423
|
+
message: Mapping[str, Any],
|
|
424
|
+
to_pubkey: str,
|
|
425
|
+
ticket: Optional[Union[str, IrohTicket]] = None,
|
|
426
|
+
timeout: float = 15.0,
|
|
427
|
+
anonymous: bool = False,
|
|
428
|
+
) -> Optional[Inbound]:
|
|
429
|
+
"""Send a message and wait on the threaded response queue."""
|
|
430
|
+
thid = message.get('thid') or message.get('id')
|
|
431
|
+
if not thid:
|
|
432
|
+
raise TransportError("cannot request without a message id or thid")
|
|
433
|
+
|
|
434
|
+
q = self.listen(thid)
|
|
435
|
+
try:
|
|
436
|
+
self.send(message, to_pubkey, ticket=ticket, anonymous=anonymous, timeout=timeout)
|
|
437
|
+
try:
|
|
438
|
+
return q.get(timeout=timeout)
|
|
439
|
+
except queue.Empty:
|
|
440
|
+
return None
|
|
441
|
+
finally:
|
|
442
|
+
self.unlisten(thid)
|
|
443
|
+
|
|
444
|
+
def listen(self, thid: str) -> queue.Queue[Inbound]:
|
|
445
|
+
"""Route messages matching thread id to a dedicated queue."""
|
|
446
|
+
with self._lock:
|
|
447
|
+
q = self._waiting.get(thid)
|
|
448
|
+
if q is None:
|
|
449
|
+
q = self._waiting[thid] = queue.Queue()
|
|
450
|
+
return q
|
|
451
|
+
|
|
452
|
+
def unlisten(self, thid: str) -> None:
|
|
453
|
+
"""Deregister thread listener queue."""
|
|
454
|
+
with self._lock:
|
|
455
|
+
self._waiting.pop(thid, None)
|
|
456
|
+
|
|
457
|
+
def receive(self, timeout: Optional[float] = None) -> Optional[Inbound]:
|
|
458
|
+
"""Fetch the next delivered message from the inbox."""
|
|
459
|
+
try:
|
|
460
|
+
return self.inbox.get(timeout=timeout)
|
|
461
|
+
except queue.Empty:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def _resolve_ticket(self, to_pubkey: str, ticket: Optional[Union[str, IrohTicket]]) -> Optional[IrohTicket]:
|
|
465
|
+
if isinstance(ticket, IrohTicket):
|
|
466
|
+
return ticket
|
|
467
|
+
if isinstance(ticket, str) and ticket:
|
|
468
|
+
return IrohTicket.parse(ticket)
|
|
469
|
+
|
|
470
|
+
# Look in peer's Kind 11801 descriptor
|
|
471
|
+
desc = self.peers.descriptor(to_pubkey)
|
|
472
|
+
if desc is not None:
|
|
473
|
+
for t in desc.get('tags', []):
|
|
474
|
+
if t and t[0] == 'endpoint' and len(t) > 2 and t[1] == 'iroh':
|
|
475
|
+
return IrohTicket.parse(t[2])
|
|
476
|
+
|
|
477
|
+
return None
|