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,942 @@
|
|
|
1
|
+
"""Experimental QUIC-shaped framing over UDP for the iroh rung. Not encrypted.
|
|
2
|
+
|
|
3
|
+
What this module is: a pure-stdlib framing layer that borrows QUIC's wire
|
|
4
|
+
shapes (RFC 9000 variable-length integers, long and short headers, STREAM /
|
|
5
|
+
ACK / PING / CONNECTION_CLOSE frames, per-stream offset reassembly, a
|
|
6
|
+
STUN-style reflexive probe for NAT pinholes) so that several DIDComm
|
|
7
|
+
messages can be in flight on one UDP 4-tuple without head-of-line blocking.
|
|
8
|
+
|
|
9
|
+
What it is not: QUIC. There is no TLS 1.3 handshake, no packet protection,
|
|
10
|
+
no header protection, no connection-ID authentication. Every byte on the
|
|
11
|
+
wire is readable and forgeable by anyone on the path, and a datagram
|
|
12
|
+
claiming a connection ID is believed. The module used to describe itself as
|
|
13
|
+
a real QUIC transport; that was not true and the words are corrected here
|
|
14
|
+
because a reader deciding what this rung protects must not be misled.
|
|
15
|
+
|
|
16
|
+
Confidentiality and authenticity come from one place only: the DIDComm JWE
|
|
17
|
+
carried inside each stream. The receiving side opens every completed stream
|
|
18
|
+
with didcomm_inbound.open_inbound, which refuses plaintext and binds the
|
|
19
|
+
sender's name to the ECDH-1PU skid, so a forged datagram can at worst waste
|
|
20
|
+
a stream, never speak as a peer. Anything this layer offers beyond that -
|
|
21
|
+
ordering, reassembly, the probe - is best-effort plumbing, and it is bounded
|
|
22
|
+
(MAX_CONNECTIONS, MAX_STREAM_BYTES, MAX_CHUNKS_PER_STREAM, MAX_STREAMS_PER_CONN)
|
|
23
|
+
because an unauthenticated UDP listener's only defence against a flood is
|
|
24
|
+
to refuse to remember too much of it.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import collections
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import queue
|
|
33
|
+
import socket
|
|
34
|
+
import struct
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from typing import (
|
|
38
|
+
Any,
|
|
39
|
+
Callable,
|
|
40
|
+
Counter,
|
|
41
|
+
Deque,
|
|
42
|
+
Mapping,
|
|
43
|
+
Optional,
|
|
44
|
+
Union,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from fleetmesh_core.identity.did import did_nostr
|
|
48
|
+
from fleetmesh_core.identity import didcomm
|
|
49
|
+
from .. import didcomm_inbound
|
|
50
|
+
from .. import const as transport_const
|
|
51
|
+
from ..const import Inbound, TransportError
|
|
52
|
+
from fleetmesh_core.identity.iroh_ticket import ALPN, IrohTicket
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# QUIC Packet Header Types
|
|
57
|
+
PACKET_TYPE_INITIAL: int = 0x00
|
|
58
|
+
PACKET_TYPE_0RTT: int = 0x01
|
|
59
|
+
PACKET_TYPE_HANDSHAKE: int = 0x02
|
|
60
|
+
PACKET_TYPE_RETRY: int = 0x03
|
|
61
|
+
|
|
62
|
+
# QUIC Version (1 = RFC 9000, 0x49524f48 = IROH)
|
|
63
|
+
QUIC_VERSION_1: int = 0x00000001
|
|
64
|
+
IROH_ALPN: str = ALPN
|
|
65
|
+
|
|
66
|
+
# QUIC Frame Types
|
|
67
|
+
FRAME_PADDING: int = 0x00
|
|
68
|
+
FRAME_PING: int = 0x01
|
|
69
|
+
FRAME_ACK: int = 0x02
|
|
70
|
+
FRAME_RESET_STREAM: int = 0x04
|
|
71
|
+
FRAME_STREAM: int = 0x08 # 0x08..0x0f
|
|
72
|
+
FRAME_CONNECTION_CLOSE: int = 0x1c
|
|
73
|
+
FRAME_PROBE_REQ: int = 0x50 # Fleetmesh P2P NAT Probe Request
|
|
74
|
+
FRAME_PROBE_RESP: int = 0x51 # Fleetmesh P2P NAT Probe Response
|
|
75
|
+
|
|
76
|
+
# Stream Frame Flags
|
|
77
|
+
STREAM_FLAG_OFF: int = 0x04
|
|
78
|
+
STREAM_FLAG_LEN: int = 0x02
|
|
79
|
+
STREAM_FLAG_FIN: int = 0x01
|
|
80
|
+
|
|
81
|
+
MAX_DATAGRAM_SIZE: int = 1472 # Standard Ethernet MTU minus IP/UDP headers
|
|
82
|
+
|
|
83
|
+
# Bounds on what an unauthenticated UDP listener will remember. A stream
|
|
84
|
+
# carries one DIDComm envelope, so a stream larger than the envelope cap is
|
|
85
|
+
# not a message; chunks are counted as well because a sender can fill the
|
|
86
|
+
# byte budget with one-byte pieces at distinct offsets and the dict of
|
|
87
|
+
# offsets is what grows. Connections are evicted oldest-idle-first when the
|
|
88
|
+
# table is full, because a single host on the path can mint as many
|
|
89
|
+
# connection IDs as it has packets.
|
|
90
|
+
MAX_CONNECTIONS: int = 256
|
|
91
|
+
MAX_STREAMS_PER_CONN: int = 64
|
|
92
|
+
MAX_STREAM_BYTES: int = didcomm_inbound.MAX_ENVELOPE_BYTES
|
|
93
|
+
MAX_CHUNKS_PER_STREAM: int = 4096
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# QUIC Variable-Length Integer Encoding (RFC 9000 Section 16)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def encode_varint(v: int) -> bytes:
|
|
101
|
+
"""Encode an integer into QUIC variable-length format (1, 2, 4, or 8 bytes)."""
|
|
102
|
+
if v < 0:
|
|
103
|
+
raise ValueError(f"QUIC varint cannot be negative: {v}")
|
|
104
|
+
if v < 64:
|
|
105
|
+
return bytes([v])
|
|
106
|
+
elif v < 16384:
|
|
107
|
+
return struct.pack('>H', 0x4000 | v)
|
|
108
|
+
elif v < 1073741824:
|
|
109
|
+
return struct.pack('>I', 0x80000000 | v)
|
|
110
|
+
elif v < (1 << 62):
|
|
111
|
+
return struct.pack('>Q', 0xc000000000000000 | v)
|
|
112
|
+
raise ValueError(f"QUIC varint value out of range: {v}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
|
|
116
|
+
"""Decode QUIC variable-length integer. Returns (value, next_offset)."""
|
|
117
|
+
if offset >= len(data):
|
|
118
|
+
raise ValueError("truncated varint buffer")
|
|
119
|
+
first = data[offset]
|
|
120
|
+
prefix = first >> 6
|
|
121
|
+
if prefix == 0:
|
|
122
|
+
return first & 0x3f, offset + 1
|
|
123
|
+
elif prefix == 1:
|
|
124
|
+
if offset + 2 > len(data):
|
|
125
|
+
raise ValueError("truncated 2-byte varint")
|
|
126
|
+
val = struct.unpack('>H', data[offset:offset + 2])[0] & 0x3fff
|
|
127
|
+
return val, offset + 2
|
|
128
|
+
elif prefix == 2:
|
|
129
|
+
if offset + 4 > len(data):
|
|
130
|
+
raise ValueError("truncated 4-byte varint")
|
|
131
|
+
val = struct.unpack('>I', data[offset:offset + 4])[0] & 0x3fffffff
|
|
132
|
+
return val, offset + 4
|
|
133
|
+
else:
|
|
134
|
+
if offset + 8 > len(data):
|
|
135
|
+
raise ValueError("truncated 8-byte varint")
|
|
136
|
+
val = struct.unpack('>Q', data[offset:offset + 8])[0] & 0x3fffffffffffffff
|
|
137
|
+
return val, offset + 8
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# QUIC Frame Structures
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class StreamFrame:
|
|
145
|
+
"""QUIC STREAM Frame (RFC 9000 Section 19.8)."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
stream_id: int,
|
|
150
|
+
offset: int = 0,
|
|
151
|
+
data: bytes = b'',
|
|
152
|
+
fin: bool = False,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.stream_id: int = stream_id
|
|
155
|
+
self.offset: int = offset
|
|
156
|
+
self.data: bytes = data
|
|
157
|
+
self.fin: bool = fin
|
|
158
|
+
|
|
159
|
+
def encode(self) -> bytes:
|
|
160
|
+
flags = 0x08
|
|
161
|
+
if self.offset > 0:
|
|
162
|
+
flags |= STREAM_FLAG_OFF
|
|
163
|
+
flags |= STREAM_FLAG_LEN
|
|
164
|
+
if self.fin:
|
|
165
|
+
flags |= STREAM_FLAG_FIN
|
|
166
|
+
|
|
167
|
+
out = [bytes([flags]), encode_varint(self.stream_id)]
|
|
168
|
+
if self.offset > 0:
|
|
169
|
+
out.append(encode_varint(self.offset))
|
|
170
|
+
out.append(encode_varint(len(self.data)))
|
|
171
|
+
out.append(self.data)
|
|
172
|
+
return b''.join(out)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def decode(cls, data: bytes, offset: int) -> tuple[StreamFrame, int]:
|
|
176
|
+
first = data[offset]
|
|
177
|
+
flags = first
|
|
178
|
+
offset += 1
|
|
179
|
+
|
|
180
|
+
stream_id, offset = decode_varint(data, offset)
|
|
181
|
+
stream_offset = 0
|
|
182
|
+
if flags & STREAM_FLAG_OFF:
|
|
183
|
+
stream_offset, offset = decode_varint(data, offset)
|
|
184
|
+
|
|
185
|
+
if flags & STREAM_FLAG_LEN:
|
|
186
|
+
length, offset = decode_varint(data, offset)
|
|
187
|
+
else:
|
|
188
|
+
length = len(data) - offset
|
|
189
|
+
|
|
190
|
+
chunk = data[offset:offset + length]
|
|
191
|
+
offset += length
|
|
192
|
+
fin = bool(flags & STREAM_FLAG_FIN)
|
|
193
|
+
return cls(stream_id=stream_id, offset=stream_offset, data=chunk, fin=fin), offset
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AckFrame:
|
|
197
|
+
"""QUIC ACK Frame (RFC 9000 Section 19.3)."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, largest_ack: int, ack_delay_ms: int = 0) -> None:
|
|
200
|
+
self.largest_ack: int = largest_ack
|
|
201
|
+
self.ack_delay_ms: int = ack_delay_ms
|
|
202
|
+
|
|
203
|
+
def encode(self) -> bytes:
|
|
204
|
+
return b''.join([
|
|
205
|
+
bytes([FRAME_ACK]),
|
|
206
|
+
encode_varint(self.largest_ack),
|
|
207
|
+
encode_varint(self.ack_delay_ms),
|
|
208
|
+
encode_varint(0), # ACK range count
|
|
209
|
+
encode_varint(0), # First ACK range
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def decode(cls, data: bytes, offset: int) -> tuple[AckFrame, int]:
|
|
214
|
+
offset += 1 # Skip frame type byte
|
|
215
|
+
largest_ack, offset = decode_varint(data, offset)
|
|
216
|
+
ack_delay, offset = decode_varint(data, offset)
|
|
217
|
+
range_count, offset = decode_varint(data, offset)
|
|
218
|
+
first_range, offset = decode_varint(data, offset)
|
|
219
|
+
for _ in range(range_count):
|
|
220
|
+
_, offset = decode_varint(data, offset)
|
|
221
|
+
_, offset = decode_varint(data, offset)
|
|
222
|
+
return cls(largest_ack=largest_ack, ack_delay_ms=ack_delay), offset
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ConnectionCloseFrame:
|
|
226
|
+
"""QUIC CONNECTION_CLOSE Frame (RFC 9000 Section 19.19)."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, error_code: int = 0, reason: str = '') -> None:
|
|
229
|
+
self.error_code: int = error_code
|
|
230
|
+
self.reason: str = reason
|
|
231
|
+
|
|
232
|
+
def encode(self) -> bytes:
|
|
233
|
+
reason_bytes = self.reason.encode('utf-8')
|
|
234
|
+
return b''.join([
|
|
235
|
+
bytes([FRAME_CONNECTION_CLOSE]),
|
|
236
|
+
encode_varint(self.error_code),
|
|
237
|
+
encode_varint(0), # frame type
|
|
238
|
+
encode_varint(len(reason_bytes)),
|
|
239
|
+
reason_bytes,
|
|
240
|
+
])
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def decode(cls, data: bytes, offset: int) -> tuple[ConnectionCloseFrame, int]:
|
|
244
|
+
offset += 1
|
|
245
|
+
code, offset = decode_varint(data, offset)
|
|
246
|
+
_, offset = decode_varint(data, offset)
|
|
247
|
+
rlen, offset = decode_varint(data, offset)
|
|
248
|
+
reason = data[offset:offset + rlen].decode('utf-8', errors='replace')
|
|
249
|
+
return cls(error_code=code, reason=reason), offset + rlen
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# QUIC Packet Serialization (Long and Short Headers)
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
class QuicPacket:
|
|
257
|
+
"""QUIC Packet representation (Long Header for handshake, Short Header for 1-RTT data)."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
is_long_header: bool,
|
|
262
|
+
dcid: bytes,
|
|
263
|
+
packet_number: int,
|
|
264
|
+
scid: Optional[bytes] = None,
|
|
265
|
+
packet_type: int = PACKET_TYPE_INITIAL,
|
|
266
|
+
version: int = QUIC_VERSION_1,
|
|
267
|
+
payload: bytes = b'',
|
|
268
|
+
) -> None:
|
|
269
|
+
self.is_long_header: bool = is_long_header
|
|
270
|
+
self.dcid: bytes = dcid
|
|
271
|
+
self.scid: bytes = scid or b''
|
|
272
|
+
self.packet_number: int = packet_number
|
|
273
|
+
self.packet_type: int = packet_type
|
|
274
|
+
self.version: int = version
|
|
275
|
+
self.payload: bytes = payload
|
|
276
|
+
|
|
277
|
+
def encode(self) -> bytes:
|
|
278
|
+
pn_bytes = struct.pack('>I', self.packet_number)
|
|
279
|
+
if self.is_long_header:
|
|
280
|
+
# Long Header: Form=1 (0x80), Fixed=1 (0x40), Type (2 bits), PN len (2 bits: 3 = 4 bytes)
|
|
281
|
+
first_byte = 0xc0 | (self.packet_type << 4) | 0x03
|
|
282
|
+
header = struct.pack('>B I B', first_byte, self.version, len(self.dcid))
|
|
283
|
+
header += self.dcid
|
|
284
|
+
header += bytes([len(self.scid)]) + self.scid
|
|
285
|
+
# Token length = 0 for normal packets
|
|
286
|
+
header += encode_varint(0)
|
|
287
|
+
# Length = len(pn_bytes) + len(self.payload)
|
|
288
|
+
header += encode_varint(len(pn_bytes) + len(self.payload))
|
|
289
|
+
return header + pn_bytes + self.payload
|
|
290
|
+
else:
|
|
291
|
+
# Short Header: Form=0 (0x00), Fixed=1 (0x40), Key Phase=0, PN len=3 (4 bytes)
|
|
292
|
+
first_byte = 0x43
|
|
293
|
+
header = bytes([first_byte]) + self.dcid + pn_bytes
|
|
294
|
+
return header + self.payload
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def decode(cls, data: bytes) -> QuicPacket:
|
|
298
|
+
if len(data) < 2:
|
|
299
|
+
raise ValueError("datagram too short for QUIC header")
|
|
300
|
+
first = data[0]
|
|
301
|
+
is_long = bool(first & 0x80)
|
|
302
|
+
|
|
303
|
+
if is_long:
|
|
304
|
+
if len(data) < 7:
|
|
305
|
+
raise ValueError("datagram too short for QUIC long header")
|
|
306
|
+
ptype = (first >> 4) & 0x03
|
|
307
|
+
version = struct.unpack('>I', data[1:5])[0]
|
|
308
|
+
dcil = data[5]
|
|
309
|
+
offset = 6
|
|
310
|
+
if offset + dcil > len(data):
|
|
311
|
+
raise ValueError("truncated destination CID in long header")
|
|
312
|
+
dcid = data[offset:offset + dcil]
|
|
313
|
+
offset += dcil
|
|
314
|
+
|
|
315
|
+
if offset >= len(data):
|
|
316
|
+
raise ValueError("missing source CID length in long header")
|
|
317
|
+
scil = data[offset]
|
|
318
|
+
offset += 1
|
|
319
|
+
if offset + scil > len(data):
|
|
320
|
+
raise ValueError("truncated source CID in long header")
|
|
321
|
+
scid = data[offset:offset + scil]
|
|
322
|
+
offset += scil
|
|
323
|
+
|
|
324
|
+
token_len, offset = decode_varint(data, offset)
|
|
325
|
+
offset += token_len
|
|
326
|
+
|
|
327
|
+
length, offset = decode_varint(data, offset)
|
|
328
|
+
if offset + 4 > len(data):
|
|
329
|
+
raise ValueError("truncated packet number in long header")
|
|
330
|
+
pn = struct.unpack('>I', data[offset:offset + 4])[0]
|
|
331
|
+
offset += 4
|
|
332
|
+
payload = data[offset:offset + (length - 4)]
|
|
333
|
+
return cls(
|
|
334
|
+
is_long_header=True,
|
|
335
|
+
dcid=dcid,
|
|
336
|
+
scid=scid,
|
|
337
|
+
packet_number=pn,
|
|
338
|
+
packet_type=ptype,
|
|
339
|
+
version=version,
|
|
340
|
+
payload=payload,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
# Short header: 1-byte flag, 8-byte DCID, 4-byte PN
|
|
344
|
+
if len(data) < 13:
|
|
345
|
+
raise ValueError("datagram too short for QUIC short header")
|
|
346
|
+
dcid = data[1:9]
|
|
347
|
+
pn = struct.unpack('>I', data[9:13])[0]
|
|
348
|
+
payload = data[13:]
|
|
349
|
+
return cls(
|
|
350
|
+
is_long_header=False,
|
|
351
|
+
dcid=dcid,
|
|
352
|
+
packet_number=pn,
|
|
353
|
+
payload=payload,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Multiplexed Stream State
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
class StreamRefused(Exception):
|
|
362
|
+
"""A stream grew past what one message may be; it is abandoned."""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class QuicStream:
|
|
366
|
+
"""A multiplexed unidirectional or bidirectional stream on a QUIC connection."""
|
|
367
|
+
|
|
368
|
+
def __init__(self, stream_id: int, on_complete: Optional[Callable[[int, bytes], None]] = None) -> None:
|
|
369
|
+
self.stream_id: int = stream_id
|
|
370
|
+
self.on_complete: Optional[Callable[[int, bytes], None]] = on_complete
|
|
371
|
+
self.chunks: dict[int, bytes] = {} # offset -> data
|
|
372
|
+
self.received_bytes: int = 0
|
|
373
|
+
self.fin_received: bool = False
|
|
374
|
+
self.final_offset: Optional[int] = None
|
|
375
|
+
self.completed: bool = False
|
|
376
|
+
self.refused: bool = False
|
|
377
|
+
self.send_offset: int = 0
|
|
378
|
+
self._lock: threading.Lock = threading.Lock()
|
|
379
|
+
|
|
380
|
+
def receive_chunk(self, offset: int, data: bytes, fin: bool) -> None:
|
|
381
|
+
with self._lock:
|
|
382
|
+
if self.completed or self.refused:
|
|
383
|
+
return
|
|
384
|
+
if data:
|
|
385
|
+
# Bound before storing: the offset alone can name a byte
|
|
386
|
+
# position past any message, and the byte count is the sum
|
|
387
|
+
# of what has actually been kept, so neither a far offset
|
|
388
|
+
# nor a flood of tiny pieces gets to grow the dict.
|
|
389
|
+
if offset + len(data) > MAX_STREAM_BYTES or self.received_bytes + len(data) > MAX_STREAM_BYTES:
|
|
390
|
+
self.refused = True
|
|
391
|
+
self.chunks.clear()
|
|
392
|
+
raise StreamRefused('stream %d exceeds %d bytes' % (self.stream_id, MAX_STREAM_BYTES))
|
|
393
|
+
if offset not in self.chunks and len(self.chunks) >= MAX_CHUNKS_PER_STREAM:
|
|
394
|
+
self.refused = True
|
|
395
|
+
self.chunks.clear()
|
|
396
|
+
raise StreamRefused('stream %d exceeds %d chunks' % (self.stream_id, MAX_CHUNKS_PER_STREAM))
|
|
397
|
+
self.received_bytes += len(data)
|
|
398
|
+
self.chunks[offset] = data
|
|
399
|
+
if fin:
|
|
400
|
+
self.fin_received = True
|
|
401
|
+
self.final_offset = offset + len(data)
|
|
402
|
+
|
|
403
|
+
if self.fin_received and not self.completed:
|
|
404
|
+
target_len = self.final_offset if self.final_offset is not None else 0
|
|
405
|
+
if target_len == 0:
|
|
406
|
+
self.completed = True
|
|
407
|
+
if self.on_complete:
|
|
408
|
+
self.on_complete(self.stream_id, b'')
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
# Assemble contiguous chunks starting from 0
|
|
412
|
+
assembled = bytearray()
|
|
413
|
+
curr = 0
|
|
414
|
+
sorted_offsets = sorted(self.chunks.keys())
|
|
415
|
+
for off in sorted_offsets:
|
|
416
|
+
if off == curr:
|
|
417
|
+
chunk = self.chunks[off]
|
|
418
|
+
assembled.extend(chunk)
|
|
419
|
+
curr += len(chunk)
|
|
420
|
+
elif off < curr:
|
|
421
|
+
overlap = curr - off
|
|
422
|
+
if overlap < len(self.chunks[off]):
|
|
423
|
+
chunk = self.chunks[off][overlap:]
|
|
424
|
+
assembled.extend(chunk)
|
|
425
|
+
curr += len(chunk)
|
|
426
|
+
|
|
427
|
+
if curr >= target_len:
|
|
428
|
+
self.completed = True
|
|
429
|
+
payload = bytes(assembled[:target_len])
|
|
430
|
+
if self.on_complete:
|
|
431
|
+
self.on_complete(self.stream_id, payload)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# QUIC Connection State Machine
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
class QuicConnection:
|
|
439
|
+
"""Manages state for one peer-to-peer QUIC connection."""
|
|
440
|
+
|
|
441
|
+
def __init__(
|
|
442
|
+
self,
|
|
443
|
+
local_cid: bytes,
|
|
444
|
+
remote_cid: bytes,
|
|
445
|
+
remote_addr: tuple[str, int],
|
|
446
|
+
is_server: bool = False,
|
|
447
|
+
on_stream_complete: Optional[Callable[[QuicConnection, int, bytes], None]] = None,
|
|
448
|
+
) -> None:
|
|
449
|
+
self.local_cid: bytes = local_cid
|
|
450
|
+
self.remote_cid: bytes = remote_cid
|
|
451
|
+
self.remote_addr: tuple[str, int] = remote_addr
|
|
452
|
+
self.is_server: bool = is_server
|
|
453
|
+
self.on_stream_complete: Optional[Callable[[QuicConnection, int, bytes], None]] = on_stream_complete
|
|
454
|
+
self.state: str = 'INITIAL' if not is_server else 'ESTABLISHED'
|
|
455
|
+
|
|
456
|
+
self.next_packet_number: int = 0
|
|
457
|
+
self.largest_received_pn: int = -1
|
|
458
|
+
self.rtt_estimate_ms: float = 20.0
|
|
459
|
+
self.last_sent_at: float = 0.0
|
|
460
|
+
self.last_received_at: float = time.time()
|
|
461
|
+
|
|
462
|
+
self.streams: dict[int, QuicStream] = {}
|
|
463
|
+
self.next_stream_id: int = 0 if not is_server else 1
|
|
464
|
+
self._lock: threading.Lock = threading.Lock()
|
|
465
|
+
|
|
466
|
+
# Completed streams queue
|
|
467
|
+
self.incoming_messages: queue.Queue[bytes] = queue.Queue(maxsize=1000)
|
|
468
|
+
|
|
469
|
+
def next_pn(self) -> int:
|
|
470
|
+
with self._lock:
|
|
471
|
+
pn = self.next_packet_number
|
|
472
|
+
self.next_packet_number += 1
|
|
473
|
+
return pn
|
|
474
|
+
|
|
475
|
+
def get_or_create_stream(self, stream_id: int) -> QuicStream:
|
|
476
|
+
with self._lock:
|
|
477
|
+
if stream_id not in self.streams:
|
|
478
|
+
# Completed streams are dropped as new ones open, so the
|
|
479
|
+
# cap counts streams still being assembled, not history.
|
|
480
|
+
if len(self.streams) >= MAX_STREAMS_PER_CONN:
|
|
481
|
+
for sid in [k for k, v in self.streams.items() if v.completed or v.refused]:
|
|
482
|
+
del self.streams[sid]
|
|
483
|
+
if len(self.streams) >= MAX_STREAMS_PER_CONN:
|
|
484
|
+
raise StreamRefused('connection has %d open streams' % MAX_STREAMS_PER_CONN)
|
|
485
|
+
s = QuicStream(stream_id, on_complete=self._on_stream_complete)
|
|
486
|
+
self.streams[stream_id] = s
|
|
487
|
+
return self.streams[stream_id]
|
|
488
|
+
|
|
489
|
+
def _on_stream_complete(self, stream_id: int, data: bytes) -> None:
|
|
490
|
+
try:
|
|
491
|
+
self.incoming_messages.put_nowait(data)
|
|
492
|
+
except queue.Full:
|
|
493
|
+
# Bounded (1000) per connection; a blocking put here would stall
|
|
494
|
+
# the engine's receive loop on one slow consumer. The engine's
|
|
495
|
+
# raw_inbox is the delivery path anyway; this queue is a mirror.
|
|
496
|
+
log.warning('iroh_live: per-connection inbox full, stream %d not mirrored', stream_id)
|
|
497
|
+
if self.on_stream_complete:
|
|
498
|
+
try:
|
|
499
|
+
self.on_stream_complete(self, stream_id, data)
|
|
500
|
+
except Exception:
|
|
501
|
+
# The callback is the engine's (or a test's); a fault in it
|
|
502
|
+
# must not kill the UDP receive loop that called us, but it
|
|
503
|
+
# is a bug and gets logged, not hidden.
|
|
504
|
+
log.exception('iroh_live: on_stream_complete callback failed for stream %d', stream_id)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# Live Iroh P2P QUIC Transport Engine
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
class IrohLiveEngine:
|
|
512
|
+
"""High-performance UDP-based QUIC P2P streaming engine for Fleetmesh.
|
|
513
|
+
|
|
514
|
+
Zero external dependencies: uses Python standard library socket.SOCK_DGRAM.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
def __init__(
|
|
518
|
+
self,
|
|
519
|
+
node_id: str,
|
|
520
|
+
host: str = '127.0.0.1',
|
|
521
|
+
port: int = 0,
|
|
522
|
+
alpn: str = IROH_ALPN,
|
|
523
|
+
timeout: float = 5.0,
|
|
524
|
+
) -> None:
|
|
525
|
+
self.node_id: str = node_id.lower()
|
|
526
|
+
self.host: str = host
|
|
527
|
+
self.port: int = port
|
|
528
|
+
self.alpn: str = alpn
|
|
529
|
+
self.timeout: float = timeout
|
|
530
|
+
|
|
531
|
+
self.sock: Optional[socket.socket] = None
|
|
532
|
+
self._rx_thread: Optional[threading.Thread] = None
|
|
533
|
+
self._running: bool = False
|
|
534
|
+
|
|
535
|
+
self.connections: dict[bytes, QuicConnection] = {} # CID -> QuicConnection
|
|
536
|
+
self.addr_to_conn: dict[tuple[str, int], QuicConnection] = {} # (host, port) -> QuicConnection
|
|
537
|
+
self._lock: threading.Lock = threading.Lock()
|
|
538
|
+
|
|
539
|
+
# Telemetry & Performance counters
|
|
540
|
+
self.packets_in: int = 0
|
|
541
|
+
self.packets_out: int = 0
|
|
542
|
+
self.bytes_in: int = 0
|
|
543
|
+
self.bytes_out: int = 0
|
|
544
|
+
self.streams_in: int = 0
|
|
545
|
+
self.streams_out: int = 0
|
|
546
|
+
self.dropped: Deque[tuple[str, Optional[str]]] = collections.deque(maxlen=200)
|
|
547
|
+
# Replay and freshness for streams this engine opens itself
|
|
548
|
+
# (receive_didcomm). Wall clock, as send_didcomm dates with.
|
|
549
|
+
self.guard: didcomm_inbound.InboundGuard = didcomm_inbound.InboundGuard()
|
|
550
|
+
self.anonymous_refused: int = 0
|
|
551
|
+
self.evicted: int = 0
|
|
552
|
+
self.drop_counts: Counter[str] = collections.Counter()
|
|
553
|
+
self.probe_responses: dict[str, tuple[str, int]] = {} # nonce -> (reflexive_ip, reflexive_port)
|
|
554
|
+
|
|
555
|
+
# Inbound queues for multiplexed streams and unpacked DIDComm events
|
|
556
|
+
self.raw_inbox: queue.Queue[tuple[QuicConnection, int, bytes]] = queue.Queue(maxsize=10000)
|
|
557
|
+
self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=10000)
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def endpoint(self) -> str:
|
|
561
|
+
return f"{self.host}:{self.port}"
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def ticket(self) -> str:
|
|
565
|
+
return IrohTicket(self.node_id, self.host, self.port).encode()
|
|
566
|
+
|
|
567
|
+
def start(self) -> IrohLiveEngine:
|
|
568
|
+
"""Bind UDP socket on host:port and launch background async receiver thread."""
|
|
569
|
+
if self.sock is not None:
|
|
570
|
+
return self
|
|
571
|
+
|
|
572
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
573
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
574
|
+
s.bind((self.host, self.port))
|
|
575
|
+
self.port = s.getsockname()[1]
|
|
576
|
+
s.settimeout(0.5)
|
|
577
|
+
self.sock = s
|
|
578
|
+
|
|
579
|
+
self._running = True
|
|
580
|
+
self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True, name=f"iroh-quic-{self.port}")
|
|
581
|
+
self._rx_thread.start()
|
|
582
|
+
return self
|
|
583
|
+
|
|
584
|
+
def stop(self) -> None:
|
|
585
|
+
"""Gracefully stop engine and close UDP socket."""
|
|
586
|
+
self._running = False
|
|
587
|
+
if self.sock:
|
|
588
|
+
try:
|
|
589
|
+
self.sock.close()
|
|
590
|
+
except OSError:
|
|
591
|
+
pass # best-effort close; the socket may already be gone
|
|
592
|
+
self.sock = None
|
|
593
|
+
if self._rx_thread and self._rx_thread.is_alive():
|
|
594
|
+
self._rx_thread.join(timeout=1.0)
|
|
595
|
+
|
|
596
|
+
def _evict_idle_locked(self) -> None:
|
|
597
|
+
"""Forget the connection idle longest. Called with self._lock held."""
|
|
598
|
+
by_addr = list(self.addr_to_conn.items())
|
|
599
|
+
if not by_addr:
|
|
600
|
+
return
|
|
601
|
+
addr, victim = min(by_addr, key=lambda kv: kv[1].last_received_at)
|
|
602
|
+
self.addr_to_conn.pop(addr, None)
|
|
603
|
+
for cid in [k for k, v in self.connections.items() if v is victim]:
|
|
604
|
+
del self.connections[cid]
|
|
605
|
+
self.evicted += 1
|
|
606
|
+
|
|
607
|
+
def _drop(self, reason: str, ident: Optional[str] = None) -> None:
|
|
608
|
+
prefix = reason.split(':', 1)[0].strip()
|
|
609
|
+
self.drop_counts[prefix] += 1
|
|
610
|
+
self.dropped.append((reason, ident))
|
|
611
|
+
|
|
612
|
+
def _on_conn_stream_complete(self, conn: QuicConnection, stream_id: int, data: bytes) -> None:
|
|
613
|
+
try:
|
|
614
|
+
self.raw_inbox.put_nowait((conn, stream_id, data))
|
|
615
|
+
except queue.Full:
|
|
616
|
+
# raw_inbox is bounded (10000): a full one means the consumer
|
|
617
|
+
# is not keeping up, and the stream is dropped and counted
|
|
618
|
+
# like every other drop rather than blocking the receive loop.
|
|
619
|
+
self._drop('inbox full: raw_inbox', '%s:%d' % conn.remote_addr)
|
|
620
|
+
|
|
621
|
+
# -----------------------------------------------------------------------
|
|
622
|
+
# Background Packet Receive Loop
|
|
623
|
+
# -----------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
def _rx_loop(self) -> None:
|
|
626
|
+
while self._running and self.sock:
|
|
627
|
+
try:
|
|
628
|
+
raw_bytes, sender_addr = self.sock.recvfrom(65535)
|
|
629
|
+
except (socket.timeout, OSError):
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
if not raw_bytes:
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
self.packets_in += 1
|
|
636
|
+
self.bytes_in += len(raw_bytes)
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
# 1. Check for NAT Probe frames
|
|
640
|
+
if raw_bytes.startswith(bytes([FRAME_PROBE_REQ])):
|
|
641
|
+
nonce = raw_bytes[1:17].hex()
|
|
642
|
+
resp = (
|
|
643
|
+
bytes([FRAME_PROBE_RESP])
|
|
644
|
+
+ bytes.fromhex(nonce)
|
|
645
|
+
+ socket.inet_aton(sender_addr[0])
|
|
646
|
+
+ struct.pack('>H', sender_addr[1])
|
|
647
|
+
)
|
|
648
|
+
if self.sock:
|
|
649
|
+
try:
|
|
650
|
+
self.sock.sendto(resp, sender_addr)
|
|
651
|
+
except OSError:
|
|
652
|
+
pass
|
|
653
|
+
continue
|
|
654
|
+
elif raw_bytes.startswith(bytes([FRAME_PROBE_RESP])):
|
|
655
|
+
nonce = raw_bytes[1:17].hex()
|
|
656
|
+
ref_ip = socket.inet_ntoa(raw_bytes[17:21])
|
|
657
|
+
ref_port = struct.unpack('>H', raw_bytes[21:23])[0]
|
|
658
|
+
self.probe_responses[nonce] = (ref_ip, ref_port)
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
# 2. Parse QUIC packet
|
|
662
|
+
pkt = QuicPacket.decode(raw_bytes)
|
|
663
|
+
self._handle_packet(pkt, sender_addr)
|
|
664
|
+
except Exception as e:
|
|
665
|
+
self._drop(f"bad packet: {e}", f"{sender_addr[0]}:{sender_addr[1]}")
|
|
666
|
+
|
|
667
|
+
def _handle_packet(self, pkt: QuicPacket, sender_addr: tuple[str, int]) -> None:
|
|
668
|
+
conn = None
|
|
669
|
+
with self._lock:
|
|
670
|
+
# Match by DCID or sender address
|
|
671
|
+
conn = self.connections.get(pkt.dcid) or self.addr_to_conn.get(sender_addr)
|
|
672
|
+
if conn is None and pkt.is_long_header:
|
|
673
|
+
# Inbound new connection request. When the table is full
|
|
674
|
+
# the connection that has been quiet longest goes; a peer
|
|
675
|
+
# that is still talking is never the one evicted for a
|
|
676
|
+
# stranger's first packet.
|
|
677
|
+
if len(self.addr_to_conn) >= MAX_CONNECTIONS:
|
|
678
|
+
self._evict_idle_locked()
|
|
679
|
+
local_cid = pkt.dcid
|
|
680
|
+
remote_cid = pkt.scid or os.urandom(8)
|
|
681
|
+
conn = QuicConnection(
|
|
682
|
+
local_cid=local_cid,
|
|
683
|
+
remote_cid=remote_cid,
|
|
684
|
+
remote_addr=sender_addr,
|
|
685
|
+
is_server=True,
|
|
686
|
+
on_stream_complete=self._on_conn_stream_complete,
|
|
687
|
+
)
|
|
688
|
+
self.connections[local_cid] = conn
|
|
689
|
+
self.connections[remote_cid] = conn
|
|
690
|
+
self.addr_to_conn[sender_addr] = conn
|
|
691
|
+
|
|
692
|
+
if conn is None:
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
conn.last_received_at = time.time()
|
|
696
|
+
conn.largest_received_pn = max(conn.largest_received_pn, pkt.packet_number)
|
|
697
|
+
|
|
698
|
+
# Parse frames inside payload
|
|
699
|
+
offset = 0
|
|
700
|
+
data = pkt.payload
|
|
701
|
+
while offset < len(data):
|
|
702
|
+
ftype = data[offset]
|
|
703
|
+
if ftype == FRAME_PING:
|
|
704
|
+
offset += 1
|
|
705
|
+
self._send_ack(conn)
|
|
706
|
+
elif ftype == FRAME_ACK:
|
|
707
|
+
ack, offset = AckFrame.decode(data, offset)
|
|
708
|
+
elif (ftype & 0xf8) == FRAME_STREAM:
|
|
709
|
+
sframe, offset = StreamFrame.decode(data, offset)
|
|
710
|
+
self.streams_in += 1
|
|
711
|
+
try:
|
|
712
|
+
stream = conn.get_or_create_stream(sframe.stream_id)
|
|
713
|
+
stream.receive_chunk(sframe.offset, sframe.data, sframe.fin)
|
|
714
|
+
except StreamRefused as e:
|
|
715
|
+
self._drop('stream refused: %s' % e, f"{sender_addr[0]}:{sender_addr[1]}")
|
|
716
|
+
continue
|
|
717
|
+
self._send_ack(conn)
|
|
718
|
+
elif ftype == FRAME_CONNECTION_CLOSE:
|
|
719
|
+
cclose, offset = ConnectionCloseFrame.decode(data, offset)
|
|
720
|
+
conn.state = 'CLOSED'
|
|
721
|
+
else:
|
|
722
|
+
offset += 1 # Skip unknown byte
|
|
723
|
+
|
|
724
|
+
def _send_ack(self, conn: QuicConnection) -> None:
|
|
725
|
+
if conn.largest_received_pn >= 0:
|
|
726
|
+
ack_frame = AckFrame(conn.largest_received_pn, ack_delay_ms=2)
|
|
727
|
+
pkt = QuicPacket(
|
|
728
|
+
is_long_header=False,
|
|
729
|
+
dcid=conn.remote_cid,
|
|
730
|
+
packet_number=conn.next_pn(),
|
|
731
|
+
payload=ack_frame.encode(),
|
|
732
|
+
)
|
|
733
|
+
self._send_packet(pkt, conn.remote_addr)
|
|
734
|
+
|
|
735
|
+
def _send_packet(self, pkt: QuicPacket, dest_addr: tuple[str, int]) -> None:
|
|
736
|
+
if not self.sock:
|
|
737
|
+
return
|
|
738
|
+
raw = pkt.encode()
|
|
739
|
+
try:
|
|
740
|
+
self.sock.sendto(raw, dest_addr)
|
|
741
|
+
self.packets_out += 1
|
|
742
|
+
self.bytes_out += len(raw)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
self._drop(f"send failed: {e}", f"{dest_addr[0]}:{dest_addr[1]}")
|
|
745
|
+
|
|
746
|
+
# -----------------------------------------------------------------------
|
|
747
|
+
# Connection Management & Stream Dispatch
|
|
748
|
+
# -----------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
def get_or_connect(self, ticket: Union[str, IrohTicket]) -> QuicConnection:
|
|
751
|
+
"""Establish or retrieve live QUIC connection to an Iroh peer ticket."""
|
|
752
|
+
t = IrohTicket.parse(ticket) if isinstance(ticket, str) else ticket
|
|
753
|
+
remote_addr = (t.host, t.port)
|
|
754
|
+
|
|
755
|
+
with self._lock:
|
|
756
|
+
conn = self.addr_to_conn.get(remote_addr)
|
|
757
|
+
if conn and conn.state != 'CLOSED':
|
|
758
|
+
return conn
|
|
759
|
+
|
|
760
|
+
# Handshake: allocate connection IDs
|
|
761
|
+
local_cid = os.urandom(8)
|
|
762
|
+
remote_cid = os.urandom(8)
|
|
763
|
+
conn = QuicConnection(
|
|
764
|
+
local_cid=local_cid,
|
|
765
|
+
remote_cid=remote_cid,
|
|
766
|
+
remote_addr=remote_addr,
|
|
767
|
+
is_server=False,
|
|
768
|
+
on_stream_complete=self._on_conn_stream_complete,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
with self._lock:
|
|
772
|
+
self.connections[local_cid] = conn
|
|
773
|
+
self.connections[remote_cid] = conn
|
|
774
|
+
self.addr_to_conn[remote_addr] = conn
|
|
775
|
+
|
|
776
|
+
# Send Initial packet with PING
|
|
777
|
+
init_pkt = QuicPacket(
|
|
778
|
+
is_long_header=True,
|
|
779
|
+
dcid=remote_cid,
|
|
780
|
+
scid=local_cid,
|
|
781
|
+
packet_number=conn.next_pn(),
|
|
782
|
+
packet_type=PACKET_TYPE_INITIAL,
|
|
783
|
+
payload=bytes([FRAME_PING]),
|
|
784
|
+
)
|
|
785
|
+
self._send_packet(init_pkt, remote_addr)
|
|
786
|
+
conn.state = 'ESTABLISHED'
|
|
787
|
+
return conn
|
|
788
|
+
|
|
789
|
+
def send_stream(self, conn: QuicConnection, data: bytes, stream_id: Optional[int] = None) -> int:
|
|
790
|
+
"""Transmit payload across a new or existing multiplexed QUIC stream."""
|
|
791
|
+
sid = stream_id if stream_id is not None else conn.next_stream_id
|
|
792
|
+
if stream_id is None:
|
|
793
|
+
conn.next_stream_id += 4
|
|
794
|
+
|
|
795
|
+
# Chunk payload into MTU-safe STREAM frames
|
|
796
|
+
max_chunk = MAX_DATAGRAM_SIZE - 64
|
|
797
|
+
offset = 0
|
|
798
|
+
while offset < len(data) or len(data) == 0:
|
|
799
|
+
chunk = data[offset:offset + max_chunk]
|
|
800
|
+
offset += len(chunk)
|
|
801
|
+
fin = (offset >= len(data))
|
|
802
|
+
|
|
803
|
+
sframe = StreamFrame(stream_id=sid, offset=offset - len(chunk), data=chunk, fin=fin)
|
|
804
|
+
pkt = QuicPacket(
|
|
805
|
+
is_long_header=False,
|
|
806
|
+
dcid=conn.remote_cid,
|
|
807
|
+
packet_number=conn.next_pn(),
|
|
808
|
+
payload=sframe.encode(),
|
|
809
|
+
)
|
|
810
|
+
self._send_packet(pkt, conn.remote_addr)
|
|
811
|
+
self.streams_out += 1
|
|
812
|
+
if len(data) == 0 or offset >= len(data):
|
|
813
|
+
break
|
|
814
|
+
|
|
815
|
+
return sid
|
|
816
|
+
|
|
817
|
+
def receive_stream(self, timeout: Optional[float] = 5.0) -> Optional[tuple[QuicConnection, int, bytes]]:
|
|
818
|
+
"""Fetch completed raw stream (conn, stream_id, payload) from incoming queue."""
|
|
819
|
+
try:
|
|
820
|
+
return self.raw_inbox.get(timeout=timeout)
|
|
821
|
+
except queue.Empty:
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
def probe_nat(self, target_host: str, target_port: int, timeout: float = 1.0) -> Optional[tuple[str, int]]:
|
|
825
|
+
"""Probe remote endpoint for reflexive NAT address discovery."""
|
|
826
|
+
nonce = os.urandom(16)
|
|
827
|
+
probe_req = bytes([FRAME_PROBE_REQ]) + nonce
|
|
828
|
+
if self.sock:
|
|
829
|
+
try:
|
|
830
|
+
self.sock.sendto(probe_req, (target_host, target_port))
|
|
831
|
+
except OSError:
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
deadline = time.time() + timeout
|
|
835
|
+
n_hex = nonce.hex()
|
|
836
|
+
while time.time() < deadline:
|
|
837
|
+
if n_hex in self.probe_responses:
|
|
838
|
+
return self.probe_responses.pop(n_hex)
|
|
839
|
+
time.sleep(0.02)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
def punch_hole(self, target_host: str, target_port: int, count: int = 5, interval: float = 0.05) -> None:
|
|
843
|
+
"""Send repeated probe bursts to open/punch NAT pinholes to a remote peer."""
|
|
844
|
+
for _ in range(count):
|
|
845
|
+
nonce = os.urandom(16)
|
|
846
|
+
probe_req = bytes([FRAME_PROBE_REQ]) + nonce
|
|
847
|
+
if self.sock:
|
|
848
|
+
try:
|
|
849
|
+
self.sock.sendto(probe_req, (target_host, target_port))
|
|
850
|
+
except OSError:
|
|
851
|
+
pass
|
|
852
|
+
if interval > 0:
|
|
853
|
+
time.sleep(interval)
|
|
854
|
+
|
|
855
|
+
# -----------------------------------------------------------------------
|
|
856
|
+
# High-Level DIDComm Integration
|
|
857
|
+
# -----------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
def send_didcomm(
|
|
860
|
+
self,
|
|
861
|
+
keystore: Any,
|
|
862
|
+
peers: Any,
|
|
863
|
+
message: Mapping[str, Any],
|
|
864
|
+
to_pubkey: str,
|
|
865
|
+
ticket: Union[str, IrohTicket],
|
|
866
|
+
anonymous: bool = False,
|
|
867
|
+
) -> tuple[bool, str]:
|
|
868
|
+
"""Encrypt and dispatch a DIDComm v2 message over a QUIC stream."""
|
|
869
|
+
conn = self.get_or_connect(ticket)
|
|
870
|
+
their_agree = peers.agreement(to_pubkey) if hasattr(peers, 'agreement') else None
|
|
871
|
+
if their_agree is None:
|
|
872
|
+
raise TransportError(f"no live agreement key for peer {to_pubkey[:16]}")
|
|
873
|
+
|
|
874
|
+
msg_dict = dict(message)
|
|
875
|
+
msg_dict.setdefault('from', keystore.did)
|
|
876
|
+
recipient_did = did_nostr(to_pubkey)
|
|
877
|
+
msg_dict.setdefault('to', [recipient_did])
|
|
878
|
+
msg_dict.setdefault('created_time', int(time.time()))
|
|
879
|
+
|
|
880
|
+
kid = did_nostr(to_pubkey) + '#agree'
|
|
881
|
+
sender = None if anonymous else (keystore.kid('agreement'), keystore.agreement_priv)
|
|
882
|
+
jwe = didcomm.encrypt(msg_dict, [(kid, their_agree)], sender=sender)
|
|
883
|
+
envelope_bytes = json.dumps(jwe, separators=(',', ':'), sort_keys=True).encode('utf-8')
|
|
884
|
+
|
|
885
|
+
sid = self.send_stream(conn, envelope_bytes)
|
|
886
|
+
return True, f"delivered via iroh quic stream {sid} to {conn.remote_addr[0]}:{conn.remote_addr[1]}"
|
|
887
|
+
|
|
888
|
+
def receive_didcomm(
|
|
889
|
+
self,
|
|
890
|
+
keystore: Any,
|
|
891
|
+
peers: Any,
|
|
892
|
+
timeout: float = 5.0,
|
|
893
|
+
) -> Optional[Inbound]:
|
|
894
|
+
"""Wait for and unpack incoming DIDComm message from any active connection."""
|
|
895
|
+
deadline = time.time() + timeout
|
|
896
|
+
while time.time() < deadline:
|
|
897
|
+
rem = max(0.01, deadline - time.time())
|
|
898
|
+
try:
|
|
899
|
+
conn, stream_id, raw_bytes = self.raw_inbox.get(timeout=rem)
|
|
900
|
+
except queue.Empty:
|
|
901
|
+
break
|
|
902
|
+
|
|
903
|
+
# One rule for every rung (didcomm_inbound): a JWE this node can
|
|
904
|
+
# open, sender proven by the agreement, or a drop with a reason.
|
|
905
|
+
# The plaintext branch that used to sit here is gone; see
|
|
906
|
+
# iroh_transport._process_inbound_bytes for why.
|
|
907
|
+
inbound, why = didcomm_inbound.open_inbound(raw_bytes, keystore, peers, f"iroh-quic-{stream_id}",
|
|
908
|
+
guard=self.guard, now=int(time.time()))
|
|
909
|
+
if inbound is None:
|
|
910
|
+
self._drop(f"unpack error: {why}", f"{conn.remote_addr[0]}:{conn.remote_addr[1]}")
|
|
911
|
+
continue
|
|
912
|
+
if not inbound.authenticated and not transport_const.anonymous_allowed(inbound.message.get('type')):
|
|
913
|
+
self.anonymous_refused += 1
|
|
914
|
+
self._drop('unauthenticated: anoncrypt %s is not a first-contact type'
|
|
915
|
+
% str(inbound.message.get('type'))[:80], f"{conn.remote_addr[0]}:{conn.remote_addr[1]}")
|
|
916
|
+
continue
|
|
917
|
+
try:
|
|
918
|
+
self.inbox.put_nowait(inbound)
|
|
919
|
+
except queue.Full:
|
|
920
|
+
self._drop('inbox full')
|
|
921
|
+
return inbound
|
|
922
|
+
return None
|
|
923
|
+
|
|
924
|
+
def status(self) -> Mapping[str, Any]:
|
|
925
|
+
"""Telemetry snapshot of the live Iroh QUIC engine."""
|
|
926
|
+
return {
|
|
927
|
+
'node_id': self.node_id,
|
|
928
|
+
'host': self.host,
|
|
929
|
+
'port': self.port,
|
|
930
|
+
'alpn': self.alpn,
|
|
931
|
+
'running': self._running,
|
|
932
|
+
'endpoint': self.endpoint,
|
|
933
|
+
'ticket': self.ticket,
|
|
934
|
+
'connections_count': len(self.addr_to_conn),
|
|
935
|
+
'packets_in': self.packets_in,
|
|
936
|
+
'packets_out': self.packets_out,
|
|
937
|
+
'bytes_in': self.bytes_in,
|
|
938
|
+
'bytes_out': self.bytes_out,
|
|
939
|
+
'streams_in': self.streams_in,
|
|
940
|
+
'streams_out': self.streams_out,
|
|
941
|
+
'drops': dict(self.drop_counts),
|
|
942
|
+
}
|