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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,666 @@
1
+ """FIPS Native Datagram API Transport Client for Fleetmesh.
2
+
3
+ Spec reference: docs/fips-native-transport.md.
4
+ Connects to the FIPS v0.5.1 native API socket (/var/run/fips/api.sock, ~/.fips/api.sock, ...),
5
+ requests FSP service flows and listener descriptors via SCM_RIGHTS, and exchanges binary-framed
6
+ datagrams addressed to peer npub identities without requiring TUN/IPv6 adapters or root privileges.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import array
11
+ import errno
12
+ import json
13
+ import os
14
+ import socket
15
+ import struct
16
+ import threading
17
+ import time
18
+ from typing import Any, Mapping, MutableMapping, Optional, Union
19
+
20
+ from .types import FipsArrivalInfo
21
+
22
+ # Protocol Framing Constants (docs/fips-native-transport.md section 6)
23
+ FRAMING_MAGIC: int = 0x4D # 'M'
24
+ FRAMING_VERSION: int = 0x01
25
+ FRAME_MAGIC: bytes = bytes([FRAMING_MAGIC, FRAMING_VERSION])
26
+ HEADER_FORMAT: str = '!BBHBBH'
27
+ HEADER_SIZE: int = struct.calcsize(HEADER_FORMAT) # 8 bytes
28
+
29
+ FLAG_FINAL: int = 0x01
30
+ FLAG_COMPRESSED: int = 0x02
31
+ FLAG_DIDCOMM: int = 0x04
32
+ FLAG_ACK_REQUESTED: int = 0x08
33
+
34
+ # Where a FIPS daemon may be found when the operator did not say. Every path
35
+ # here is one only root or this user can create: /var/run and /run are
36
+ # root-owned, ~/.fips is ours. /tmp/fips/api.sock used to be on this list and
37
+ # is not any more, because any local user can create /tmp/fips first, and a
38
+ # node that then connected to it would hand its flows - and, until the
39
+ # envelope became mandatory on this rung, its peers' plaintext - to whoever
40
+ # squatted the name. An operator with a daemon in /tmp names it explicitly.
41
+ DEFAULT_SOCKET_PATHS: tuple[str, ...] = (
42
+ "/var/run/fips/api.sock",
43
+ "/run/fips/api.sock",
44
+ os.path.expanduser("~/.fips/api.sock"),
45
+ "/var/run/fips/control.sock",
46
+ os.path.expanduser("~/.fips/control.sock"),
47
+ )
48
+
49
+ # Standard FSP Service Ports
50
+ PORT_PEERING: int = 11801
51
+ PORT_TELEMETRY: int = 21801
52
+ PORT_ACCOUNTING: int = 30801
53
+ PORT_COMPUTE_MARKET: int = 30810
54
+ PORT_COMPUTE_STREAM: int = 30811
55
+ PORT_COMPUTE_CANCEL: int = 30812
56
+
57
+ # FIPS v0.5.1 advertises max_payload per flow (1170 over a UDP transport,
58
+ # 1362 in-process); fall back to the smaller value when a flow did not say.
59
+ DEFAULT_MAX_PAYLOAD: int = 1170
60
+ # Upper bound on frames per message; larger messages raise so the transport
61
+ # ladder can cascade to a stream-capable rung (iroh / nostr) instead.
62
+ MAX_FRAGMENTS: int = 64
63
+ REASSEMBLY_TIMEOUT_S: float = 30.0
64
+
65
+
66
+ def find_api_socket() -> Optional[str]:
67
+ """Find first existing and accessible FIPS Native API socket path, or None."""
68
+ for p in DEFAULT_SOCKET_PATHS:
69
+ if os.path.exists(p) and os.access(p, os.R_OK | os.W_OK):
70
+ return p
71
+ return None
72
+
73
+
74
+ class FipsTransportError(Exception):
75
+ """Raised when FIPS control or flow operations fail."""
76
+
77
+
78
+ def encode_frame(
79
+ seq: int,
80
+ payload: bytes,
81
+ is_final: bool = False,
82
+ is_didcomm: bool = True,
83
+ compressed: bool = False,
84
+ ack_requested: bool = False,
85
+ flags: Optional[int] = None,
86
+ reserved: int = 0,
87
+ ) -> bytes:
88
+ """Pack payload with 8-byte binary framing header.
89
+
90
+ ``reserved`` carries the message id used to reassemble fragmented messages.
91
+ """
92
+ if len(payload) > 65535:
93
+ raise FipsTransportError(f"payload exceeds maximum 65535 bytes ({len(payload)})")
94
+
95
+ if flags is not None:
96
+ computed_flags = flags
97
+ if is_final:
98
+ computed_flags |= FLAG_FINAL
99
+ else:
100
+ computed_flags = 0
101
+ if is_final:
102
+ computed_flags |= FLAG_FINAL
103
+ if compressed:
104
+ computed_flags |= FLAG_COMPRESSED
105
+ if is_didcomm:
106
+ computed_flags |= FLAG_DIDCOMM
107
+ if ack_requested:
108
+ computed_flags |= FLAG_ACK_REQUESTED
109
+
110
+ header = struct.pack(
111
+ HEADER_FORMAT,
112
+ FRAMING_MAGIC,
113
+ FRAMING_VERSION,
114
+ seq & 0xFFFF,
115
+ computed_flags,
116
+ reserved & 0xFF, # message id for fragment reassembly
117
+ len(payload),
118
+ )
119
+ return header + payload
120
+
121
+
122
+ def decode_frame_ex(data: bytes) -> tuple[int, int, int, bytes]:
123
+ """Like decode_frame but also returns the message id: (seq, flags, msg_id, payload)."""
124
+ seq, flags, payload = decode_frame(data)
125
+ return seq, flags, data[5], payload
126
+
127
+
128
+ class Reassembler:
129
+ """Rebuild messages split by FipsNativeTransport.send_message on one flow.
130
+
131
+ Frames with message id 0 are legacy single-frame messages and pass through.
132
+ Frames arrive in order on a FIPS flow but may be lost. A message is
133
+ delivered only when fragments 0..N arrive contiguously for one message id
134
+ and fragment N has FLAG_FINAL; any gap discards the partial message.
135
+ """
136
+
137
+ def __init__(self, timeout_s: float = REASSEMBLY_TIMEOUT_S, max_fragments: int = MAX_FRAGMENTS,
138
+ clock: Any = time.monotonic) -> None:
139
+ self.timeout_s = timeout_s
140
+ self.max_fragments = max_fragments
141
+ self.clock = clock
142
+ self._msg_id: Optional[int] = None
143
+ self._parts: list[bytes] = []
144
+ self._flags: int = 0
145
+ self._started: float = 0.0
146
+ self.dropped: int = 0
147
+
148
+ def _reset(self, dropped: bool) -> None:
149
+ if dropped and self._parts:
150
+ self.dropped += 1
151
+ self._msg_id, self._parts, self._flags = None, [], 0
152
+
153
+ def feed(self, seq: int, flags: int, msg_id: int, payload: bytes) -> Optional[tuple[int, bytes]]:
154
+ """Feed one frame; returns (flags, full_payload) when a message completes."""
155
+ if msg_id == 0:
156
+ # Pre-fragmentation sender (reserved byte was always 0): one frame = one message.
157
+ return flags, payload
158
+ now = self.clock()
159
+ if self._parts and now - self._started > self.timeout_s:
160
+ self._reset(dropped=True)
161
+ if seq == 0:
162
+ self._reset(dropped=True)
163
+ self._msg_id, self._flags, self._started = msg_id, flags, now
164
+ elif msg_id != self._msg_id or seq != len(self._parts):
165
+ self._reset(dropped=True) # gap, loss, or interleaving: discard
166
+ return None
167
+ self._parts.append(payload)
168
+ if flags & FLAG_FINAL:
169
+ out = (self._flags | FLAG_FINAL, b"".join(self._parts))
170
+ self._reset(dropped=False)
171
+ return out
172
+ if len(self._parts) >= self.max_fragments:
173
+ self._reset(dropped=True)
174
+ return None
175
+
176
+
177
+ def decode_frame(data: bytes) -> tuple[int, int, bytes]:
178
+ """Unpack 8-byte binary framing header.
179
+
180
+ Returns:
181
+ (seq, flags, payload)
182
+ """
183
+ if len(data) < HEADER_SIZE:
184
+ raise FipsTransportError(f"frame truncated ({len(data)} < {HEADER_SIZE} bytes)")
185
+
186
+ magic, ver, seq, flags, _, plen = struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE])
187
+ if magic != FRAMING_MAGIC:
188
+ raise FipsTransportError(f"invalid framing magic 0x{magic:02x} (expected 0x{FRAMING_MAGIC:02x})")
189
+ if ver != FRAMING_VERSION:
190
+ raise FipsTransportError(f"unsupported framing version {ver} (expected {FRAMING_VERSION})")
191
+
192
+ payload = data[HEADER_SIZE:HEADER_SIZE + plen]
193
+ if len(payload) != plen:
194
+ raise FipsTransportError(f"payload length mismatch ({len(payload)} != {plen})")
195
+
196
+ return seq, flags, payload
197
+
198
+
199
+ def pubkey_to_npub(pubkey: object) -> str:
200
+ """Normalize a pubkey (hex, did:nostr:, or npub) to standard npub format.
201
+
202
+ Takes `object` because config and CLI hand it whatever they parsed; a
203
+ non-string is stringified rather than refused, as before."""
204
+ if not isinstance(pubkey, str):
205
+ return str(pubkey)
206
+ if pubkey.startswith("npub1"):
207
+ return pubkey
208
+ if pubkey.startswith("did:nostr:"):
209
+ pubkey = pubkey[len("did:nostr:"):]
210
+ if len(pubkey) == 64:
211
+ try:
212
+ from fleetmesh_core.crypto.bech32 import bech32_encode
213
+ return bech32_encode("npub", bytes.fromhex(pubkey))
214
+ except Exception:
215
+ pass
216
+ return pubkey
217
+
218
+
219
+ class FipsNativeTransport:
220
+ """Manages bilateral datagram flows over FIPS Native Control API."""
221
+
222
+ def __init__(self, sock_path: Optional[str] = None, node_pk: Optional[str] = None) -> None:
223
+ self.sock_path = sock_path or self._discover_socket()
224
+ self.node_pk: Optional[str] = node_pk
225
+ self.ctrl_sock: Optional[socket.socket] = None
226
+ self._lock: threading.Lock = threading.Lock()
227
+ self.open_flows: dict[str, socket.socket] = {}
228
+ self.listeners: dict[int, socket.socket] = {}
229
+ # fileno -> max datagram size the daemon reported for that flow
230
+ self.flow_max_payload: dict[int, int] = {}
231
+ self._msg_counter: int = 0
232
+
233
+ @staticmethod
234
+ def _discover_socket() -> str:
235
+ for p in DEFAULT_SOCKET_PATHS:
236
+ if os.path.exists(p):
237
+ return p
238
+ return DEFAULT_SOCKET_PATHS[0]
239
+
240
+ def is_available(self) -> bool:
241
+ """Check if local FIPS control socket exists and is accessible."""
242
+ return os.path.exists(self.sock_path) and os.access(self.sock_path, os.R_OK | os.W_OK)
243
+
244
+ def connect(self) -> None:
245
+ """Connect to local FIPS Unix control socket."""
246
+ if not self.is_available():
247
+ raise FipsTransportError(f"FIPS control socket not accessible at {self.sock_path}")
248
+ with self._lock:
249
+ if self.ctrl_sock is not None:
250
+ return
251
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
252
+ s.connect(self.sock_path)
253
+ self.ctrl_sock = s
254
+
255
+ def open_flow(self, peer_npub: str, port: int, timeout_s: float = 5.0) -> socket.socket:
256
+ """Request connected FSP datagram flow descriptor from FIPS via SCM_RIGHTS."""
257
+ if self.ctrl_sock is None:
258
+ self.connect()
259
+ assert self.ctrl_sock is not None
260
+
261
+ norm_peer = pubkey_to_npub(peer_npub)
262
+ params: MutableMapping[str, Any] = {
263
+ "peer": norm_peer,
264
+ "remote_port": int(port),
265
+ }
266
+ cmd_dict: MutableMapping[str, Any] = {
267
+ "command": "connect",
268
+ "params": params,
269
+ }
270
+ if self.node_pk:
271
+ norm_caller = pubkey_to_npub(self.node_pk)
272
+ params["caller"] = norm_caller
273
+ req = json.dumps(cmd_dict) + "\n"
274
+ with self._lock:
275
+ try:
276
+ self.ctrl_sock.settimeout(timeout_s)
277
+ self.ctrl_sock.sendall(req.encode("utf-8"))
278
+
279
+ fds = array.array("i")
280
+ cmsg_space = socket.CMSG_SPACE(fds.itemsize * 4)
281
+ msg, ancdata, flags, addr = self.ctrl_sock.recvmsg(4096, cmsg_space)
282
+ except (OSError, BrokenPipeError, ConnectionResetError) as e:
283
+ try:
284
+ self.ctrl_sock.close()
285
+ except Exception:
286
+ pass
287
+ self.ctrl_sock = None
288
+ raise FipsTransportError(f"FIPS control socket disconnected: {e}")
289
+
290
+ if not msg and not ancdata:
291
+ try:
292
+ self.ctrl_sock.close()
293
+ except Exception:
294
+ pass
295
+ self.ctrl_sock = None
296
+ raise FipsTransportError("FIPS control socket closed unexpectedly (EOF)")
297
+
298
+ for cmsg_level, cmsg_type, cmsg_data in ancdata:
299
+ if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
300
+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
301
+ if fds:
302
+ raw_fd = fds[0]
303
+ for extra_fd in fds[1:]:
304
+ try:
305
+ os.close(extra_fd)
306
+ except OSError:
307
+ pass
308
+ try:
309
+ flow_sock = socket.fromfd(raw_fd, socket.AF_UNIX, socket.SOCK_DGRAM)
310
+ finally:
311
+ try:
312
+ os.close(raw_fd)
313
+ except OSError:
314
+ pass
315
+ key = f"{peer_npub}:{port}"
316
+ self.open_flows[key] = flow_sock
317
+ self._record_max_payload(flow_sock, msg)
318
+ return flow_sock
319
+
320
+ resp = {}
321
+ if msg:
322
+ try:
323
+ resp = json.loads(msg.decode("utf-8"))
324
+ except Exception:
325
+ pass
326
+ err_msg = resp.get("message") or resp.get("error") or "no descriptor received"
327
+ raise FipsTransportError(f"FIPS flow connect failed: {err_msg}")
328
+
329
+ def listen_port(self, port: int, timeout_s: float = 5.0) -> socket.socket:
330
+ """Register inbound listener on FSP port, receiving descriptor via SCM_RIGHTS."""
331
+ if self.ctrl_sock is None:
332
+ self.connect()
333
+ assert self.ctrl_sock is not None
334
+
335
+ params: MutableMapping[str, Any] = {
336
+ "local_port": int(port),
337
+ }
338
+ cmd_dict: MutableMapping[str, Any] = {
339
+ "command": "listen",
340
+ "params": params,
341
+ }
342
+ if self.node_pk:
343
+ norm_pk = pubkey_to_npub(self.node_pk)
344
+ params["node_pk"] = norm_pk
345
+ req = json.dumps(cmd_dict) + "\n"
346
+ with self._lock:
347
+ try:
348
+ self.ctrl_sock.settimeout(timeout_s)
349
+ self.ctrl_sock.sendall(req.encode("utf-8"))
350
+
351
+ fds = array.array("i")
352
+ cmsg_space = socket.CMSG_SPACE(fds.itemsize * 4)
353
+ msg, ancdata, flags, addr = self.ctrl_sock.recvmsg(4096, cmsg_space)
354
+ except (OSError, BrokenPipeError, ConnectionResetError) as e:
355
+ try:
356
+ self.ctrl_sock.close()
357
+ except Exception:
358
+ pass
359
+ self.ctrl_sock = None
360
+ raise FipsTransportError(f"FIPS control socket disconnected: {e}")
361
+
362
+ if not msg and not ancdata:
363
+ try:
364
+ self.ctrl_sock.close()
365
+ except Exception:
366
+ pass
367
+ self.ctrl_sock = None
368
+ raise FipsTransportError("FIPS control socket closed unexpectedly (EOF)")
369
+
370
+ for cmsg_level, cmsg_type, cmsg_data in ancdata:
371
+ if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
372
+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
373
+ if fds:
374
+ raw_fd = fds[0]
375
+ for extra_fd in fds[1:]:
376
+ try:
377
+ os.close(extra_fd)
378
+ except OSError:
379
+ pass
380
+ try:
381
+ listener_sock = socket.fromfd(raw_fd, socket.AF_UNIX, socket.SOCK_DGRAM)
382
+ finally:
383
+ try:
384
+ os.close(raw_fd)
385
+ except OSError:
386
+ pass
387
+ self.listeners[port] = listener_sock
388
+ return listener_sock
389
+
390
+ resp = {}
391
+ if msg:
392
+ try:
393
+ resp = json.loads(msg.decode("utf-8"))
394
+ except Exception:
395
+ pass
396
+ err_msg = resp.get("message") or resp.get("error") or "no descriptor received"
397
+ raise FipsTransportError(f"FIPS listen failed: {err_msg}")
398
+
399
+ def accept_flow(
400
+ self,
401
+ listener_sock: socket.socket,
402
+ timeout_s: Optional[float] = 5.0,
403
+ ) -> tuple[socket.socket, FipsArrivalInfo]:
404
+ """Accept an incoming flow on a registered listener socket.
405
+
406
+ In FIPS v0.5.1, when a remote peer initiates a flow to a listening port,
407
+ the listener descriptor receives an arrival notice datagram with SCM_RIGHTS
408
+ carrying the new connected flow datagram socket.
409
+
410
+ Returns:
411
+ (flow_socket, arrival_info_dict)
412
+ """
413
+ if timeout_s is not None:
414
+ listener_sock.settimeout(timeout_s)
415
+ fds = array.array("i")
416
+ cmsg_space = socket.CMSG_SPACE(fds.itemsize * 4)
417
+ msg, ancdata, flags, addr = listener_sock.recvmsg(4096, cmsg_space)
418
+ flow_fd = None
419
+ for cmsg_level, cmsg_type, cmsg_data in ancdata:
420
+ if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
421
+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
422
+ if fds:
423
+ flow_fd = fds[0]
424
+ for extra_fd in fds[1:]:
425
+ try:
426
+ os.close(extra_fd)
427
+ except OSError:
428
+ pass
429
+ break
430
+ if flow_fd is None:
431
+ raise FipsTransportError("no flow descriptor received in listener arrival")
432
+
433
+ try:
434
+ flow_sock = socket.fromfd(flow_fd, socket.AF_UNIX, socket.SOCK_DGRAM)
435
+ finally:
436
+ try:
437
+ os.close(flow_fd)
438
+ except OSError:
439
+ pass
440
+ arrival_info: FipsArrivalInfo = {}
441
+ if msg:
442
+ try:
443
+ arrival_info = json.loads(msg.decode("utf-8"))
444
+ except Exception:
445
+ pass
446
+ peer = arrival_info.get("peer")
447
+ remote_port = arrival_info.get("remote_port")
448
+ if peer and remote_port:
449
+ self.open_flows[f"{peer}:{remote_port}"] = flow_sock
450
+ self._record_max_payload(flow_sock, arrival_info)
451
+ return flow_sock, arrival_info
452
+
453
+ def accept_listener_event(
454
+ self,
455
+ listener_sock: socket.socket,
456
+ ) -> tuple[Optional[socket.socket], Mapping[str, Any], bytes]:
457
+ """Receive the next incoming event on a registered listener socket.
458
+
459
+ Returns:
460
+ (flow_sock, arrival_info, direct_msg)
461
+ If an incoming flow arrived via SCM_RIGHTS: returns (flow_sock, arrival_info, b"")
462
+ If a direct binary frame arrived: returns (None, {}, direct_msg)
463
+ If empty/timeout: returns (None, {}, b"")
464
+ """
465
+ fds = array.array("i")
466
+ cmsg_space = socket.CMSG_SPACE(fds.itemsize * 4)
467
+ msg, ancdata, flags, addr = listener_sock.recvmsg(65536, cmsg_space)
468
+ if not msg and not ancdata:
469
+ return None, {}, b""
470
+
471
+ flow_fd = None
472
+ for cmsg_level, cmsg_type, cmsg_data in ancdata:
473
+ if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
474
+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
475
+ if fds:
476
+ flow_fd = fds[0]
477
+ for extra_fd in fds[1:]:
478
+ try:
479
+ os.close(extra_fd)
480
+ except OSError:
481
+ pass
482
+ break
483
+
484
+ if flow_fd is not None:
485
+ try:
486
+ flow_sock = socket.fromfd(flow_fd, socket.AF_UNIX, socket.SOCK_DGRAM)
487
+ finally:
488
+ try:
489
+ os.close(flow_fd)
490
+ except OSError:
491
+ pass
492
+ arrival_info = {}
493
+ if msg:
494
+ try:
495
+ arrival_info = json.loads(msg.decode("utf-8"))
496
+ peer = arrival_info.get("peer")
497
+ rport = arrival_info.get("remote_port")
498
+ if peer and rport:
499
+ self.open_flows[f"{peer}:{rport}"] = flow_sock
500
+ except (ValueError, UnicodeDecodeError, AttributeError):
501
+ pass
502
+ self._record_max_payload(flow_sock, arrival_info)
503
+ return flow_sock, arrival_info, b""
504
+
505
+ return None, {}, msg
506
+
507
+ def _record_max_payload(self, flow_sock: socket.socket, reply: Any) -> None:
508
+ """Remember the per-flow datagram ceiling from a connect reply or arrival."""
509
+ info: Any = reply
510
+ if isinstance(reply, (bytes, bytearray)):
511
+ try:
512
+ info = json.loads(bytes(reply).decode("utf-8"))
513
+ except Exception:
514
+ info = {}
515
+ if isinstance(info, dict) and isinstance(info.get("data"), dict):
516
+ info = info["data"]
517
+ mp = info.get("max_payload") if isinstance(info, dict) else None
518
+ if isinstance(mp, int) and mp > HEADER_SIZE:
519
+ try:
520
+ self.flow_max_payload[flow_sock.fileno()] = mp
521
+ except Exception:
522
+ pass
523
+
524
+ def max_payload_for(self, flow_sock: socket.socket) -> int:
525
+ """Largest datagram (header included) this flow will carry."""
526
+ try:
527
+ return self.flow_max_payload.get(flow_sock.fileno(), DEFAULT_MAX_PAYLOAD)
528
+ except Exception:
529
+ return DEFAULT_MAX_PAYLOAD
530
+
531
+ def send_message(
532
+ self,
533
+ flow_sock: socket.socket,
534
+ payload: bytes,
535
+ flags: int = 0,
536
+ max_fragments: int = MAX_FRAGMENTS,
537
+ ) -> int:
538
+ """Send one logical message, fragmenting it to fit the flow's max_payload.
539
+
540
+ Every frame carries the same message id in the header's reserved byte
541
+ and a per-message fragment index in ``seq``; the last frame sets
542
+ FLAG_FINAL. Raises FipsTransportError before sending anything if the
543
+ message would need more than ``max_fragments`` frames, so callers can
544
+ fall back to a transport without a datagram ceiling. Returns the number
545
+ of frames sent.
546
+ """
547
+ raw = bytes(payload)
548
+ chunk = self.max_payload_for(flow_sock) - HEADER_SIZE
549
+ if chunk <= 0:
550
+ raise FipsTransportError("flow max_payload too small for framing header")
551
+ parts = [raw[i:i + chunk] for i in range(0, len(raw), chunk)] or [b""]
552
+ if len(parts) > max_fragments:
553
+ raise FipsTransportError(
554
+ f"message of {len(raw)} bytes needs {len(parts)} frames "
555
+ f"(limit {max_fragments} at {chunk} bytes/frame)"
556
+ )
557
+ with self._lock:
558
+ # ids 1..255; 0 marks a legacy/unfragmented frame
559
+ self._msg_counter = self._msg_counter % 255 + 1
560
+ msg_id = self._msg_counter
561
+ base = flags & ~FLAG_FINAL
562
+ for idx, part in enumerate(parts):
563
+ fl = base | (FLAG_FINAL if idx == len(parts) - 1 else 0)
564
+ packet = encode_frame(seq=idx, payload=part, flags=fl, is_final=False, reserved=msg_id)
565
+ sent = 0
566
+ for attempt in range(10):
567
+ try:
568
+ sent = flow_sock.send(packet)
569
+ break
570
+ except OSError as e:
571
+ if e.errno == getattr(errno, 'ENOBUFS', 55) and attempt < 9:
572
+ time.sleep(0.005 * (attempt + 1))
573
+ continue
574
+ raise
575
+ if sent != len(packet):
576
+ raise FipsTransportError(f"short datagram write ({sent} of {len(packet)} bytes)")
577
+ return len(parts)
578
+
579
+ def send_framed(
580
+ self,
581
+ flow_sock: socket.socket,
582
+ payload: Union[bytes, str, Mapping[str, Any]],
583
+ seq: int = 0,
584
+ is_final: bool = False,
585
+ flags: Optional[int] = None,
586
+ ) -> int:
587
+ """Transmit binary-framed datagram over open flow socket."""
588
+ if isinstance(payload, Mapping):
589
+ raw_bytes = json.dumps(payload, sort_keys=True).encode("utf-8")
590
+ is_didcomm = True
591
+ elif isinstance(payload, str):
592
+ raw_bytes = payload.encode("utf-8")
593
+ is_didcomm = False
594
+ else:
595
+ raw_bytes = bytes(payload)
596
+ is_didcomm = False
597
+
598
+ packet = encode_frame(
599
+ seq=seq,
600
+ payload=raw_bytes,
601
+ is_final=is_final,
602
+ is_didcomm=is_didcomm,
603
+ flags=flags,
604
+ )
605
+ limit = self.max_payload_for(flow_sock)
606
+ if len(packet) > limit:
607
+ raise FipsTransportError(
608
+ f"frame of {len(packet)} bytes exceeds flow max_payload {limit}; use send_message()"
609
+ )
610
+ for attempt in range(10):
611
+ try:
612
+ return flow_sock.send(packet)
613
+ except OSError as e:
614
+ if e.errno == getattr(errno, 'ENOBUFS', 55) and attempt < 9:
615
+ time.sleep(0.005 * (attempt + 1))
616
+ continue
617
+ raise
618
+ return flow_sock.send(packet)
619
+
620
+ def recv_framed(
621
+ self,
622
+ flow_sock: socket.socket,
623
+ timeout_s: Optional[float] = 5.0,
624
+ ) -> tuple[int, bool, bytes]:
625
+ """Receive and decode framed datagram. Returns (seq, is_final, payload_bytes)."""
626
+ if timeout_s is not None:
627
+ flow_sock.settimeout(timeout_s)
628
+ data = flow_sock.recv(65536)
629
+ seq, flags, payload = decode_frame(data)
630
+ is_final = bool(flags & FLAG_FINAL)
631
+ return seq, is_final, payload
632
+
633
+ def recv_raw_frame(
634
+ self,
635
+ flow_sock: socket.socket,
636
+ timeout_s: Optional[float] = 5.0,
637
+ ) -> tuple[int, int, bytes]:
638
+ """Receive and decode raw frame with exact integer bitflags. Returns (seq, flags, payload_bytes)."""
639
+ if timeout_s is not None:
640
+ flow_sock.settimeout(timeout_s)
641
+ data = flow_sock.recv(65536)
642
+ return decode_frame(data)
643
+
644
+ def close(self) -> None:
645
+ """Close all open flows, listeners, and control connection."""
646
+ with self._lock:
647
+ for s in self.open_flows.values():
648
+ try:
649
+ s.close()
650
+ except Exception:
651
+ pass
652
+ self.open_flows.clear()
653
+
654
+ for s in self.listeners.values():
655
+ try:
656
+ s.close()
657
+ except Exception:
658
+ pass
659
+ self.listeners.clear()
660
+
661
+ if self.ctrl_sock is not None:
662
+ try:
663
+ self.ctrl_sock.close()
664
+ except Exception:
665
+ pass
666
+ self.ctrl_sock = None