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,161 @@
1
+ """Inbound side of the relay rung: a wrap off a relay becomes a message, or a
2
+ drop with a reason. Dedup, unwrap, reassemble fragments, open the envelope
3
+ by the rule every rung shares (didcomm_inbound), then route to the thread
4
+ waiter or the inbox.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import queue
10
+ import time
11
+ from typing import TYPE_CHECKING, Any, Mapping, Optional
12
+
13
+ from fleetmesh_core.crypto import giftwrap
14
+
15
+ from .. import didcomm_inbound
16
+ from .. import const
17
+ from ..const import Inbound, _foreign
18
+ from .types import Partial
19
+
20
+
21
+ if TYPE_CHECKING:
22
+ from .shape import RelayRungShape
23
+
24
+
25
+ class InboundMixin:
26
+ def _remember(self: RelayRungShape, wid: str, kind: Optional[int] = None) -> bool:
27
+ """True if this wrap is new. The set is capped: a wrap older than the
28
+ cap has aged past the relay's replay horizon anyway. A stored wrap is
29
+ also remembered on disk, because it outlives this process."""
30
+ with self._lock:
31
+ if wid in self._seen:
32
+ return False
33
+ if kind == giftwrap.WRAP_STORED and self.seen_store is not None and self.seen_store[0](wid):
34
+ return False
35
+ self._seen.add(wid)
36
+ self._seen_order.append(wid)
37
+ while len(self._seen_order) > self.seen_max:
38
+ self._seen.discard(self._seen_order.popleft())
39
+ if kind == giftwrap.WRAP_STORED and self.seen_store is not None:
40
+ self.seen_store[1](wid, self.clock())
41
+ return True
42
+
43
+ def _deliver(self: RelayRungShape, wrap: Any) -> None:
44
+ if not isinstance(wrap, dict) or not isinstance(wrap.get('id'), str):
45
+ self._drop('wrap: not an event', None)
46
+ return
47
+ wid: str = wrap['id']
48
+ # Counted by kind before anything else can discard it. Ephemeral wraps
49
+ # (21059) carry receipts and sync; stored wraps (1059) carry the four
50
+ # messages that must survive an offline peer, an amended grant among
51
+ # them. When every peering on a mesh stopped taking up amends while
52
+ # receipts kept flowing, nothing in this node could answer whether a
53
+ # stored wrap had arrived at all, which is the first question.
54
+ self.wraps_in[wrap.get('kind')] += 1
55
+ if not self._remember(wid, wrap.get('kind')):
56
+ # A wrap already seen used to return silently. A relay replays
57
+ # stored wraps for two days, so this is ordinary - but it is also
58
+ # exactly how a message that was seen and never acted on becomes
59
+ # invisible for ever.
60
+ self.wraps_seen_again[wrap.get('kind')] += 1
61
+ return
62
+ try:
63
+ jwe, seal_pk = giftwrap.unwrap(wrap, self.ks.node_sk, now=self.clock())
64
+ except (giftwrap.GiftWrapError, ValueError, TypeError, KeyError) as e:
65
+ self._drop('wrap: %s' % e, wid)
66
+ return
67
+ if isinstance(jwe, dict) and 'frag' in jwe:
68
+ assembled = self._reassemble(jwe, seal_pk)
69
+ if assembled is None:
70
+ return # more fragments to come
71
+ jwe = assembled
72
+ if not isinstance(jwe, dict) or 'protected' not in jwe:
73
+ # Said apart from malformed traffic when it can be. NIP-59 does not
74
+ # namespace what is inside a wrap, so kind 21059 addressed to this
75
+ # key is shared ground with every other protocol that uses it - one
76
+ # machine running two of them under one key has each daemon's
77
+ # subscription receiving the other's envelopes. It fails closed
78
+ # either way, but one reason for both had an operator reading
79
+ # somebody else's healthy traffic as a peer sending malformed
80
+ # envelopes, and the count is what they read first.
81
+ self._drop(_foreign(jwe), wid)
82
+ return
83
+ # Decrypt and check that the names agree, by the one rule every rung
84
+ # shares (didcomm_inbound). The seal signer is the third name here.
85
+ inbound, why = didcomm_inbound.open_inbound(jwe, self.ks, self.peers, wid, seal_pk=seal_pk)
86
+ if inbound is None:
87
+ self._drop(why or 'envelope: refused', wid)
88
+ return
89
+ message = inbound.message
90
+ thid = message.get('thid')
91
+ # The three names agree, so this is the peer speaking. Everything
92
+ # below is the acknowledgment machinery, which lives here below the
93
+ # protocols because it is about the transport keeping its word.
94
+ self.heard[seal_pk] = self.clock()
95
+ if isinstance(message.get('ack'), list):
96
+ self._acknowledged(message['ack'])
97
+ asks = message.get('please_ack')
98
+ if (isinstance(asks, list) and ('' in asks or message['id'] in asks)
99
+ and message.get('type') != const.EMPTY_TYPE):
100
+ # Never honour a request that arrives on a pure ack, and never
101
+ # more than once per message - the wrap dedup above already
102
+ # makes a replay not a message.
103
+ self._acks.put((message['id'], seal_pk))
104
+ if message.get('type') == const.EMPTY_TYPE:
105
+ # Headers only, by definition. Consumed here; no handler wants it.
106
+ return
107
+ with self._lock:
108
+ waiter = self._waiting.get(thid) if thid else None
109
+ try:
110
+ (waiter if waiter is not None else self.inbox).put_nowait(inbound)
111
+ except queue.Full:
112
+ self._drop('inbox full', wid)
113
+ return
114
+
115
+ def _reassemble(self: RelayRungShape, frag: Mapping[str, Any], seal_pk: str) -> Optional[Any]:
116
+ """Collect fragments by id and sender; the JWE once all have arrived."""
117
+ try:
118
+ fid, i, n, data = frag['frag'], int(frag['i']), int(frag['n']), frag['data']
119
+ except (KeyError, ValueError, TypeError):
120
+ self._drop('malformed fragment', None)
121
+ return None
122
+ if not (0 < n <= const.MAX_FRAGMENTS and 0 <= i < n) or not isinstance(data, str):
123
+ self._drop('fragment out of range', None)
124
+ return None
125
+ if not isinstance(fid, str) or len(fid) > 64 or len(data) > const.FRAGMENT_BYTES + 16:
126
+ self._drop('fragment out of range', None)
127
+ return None
128
+ key = (seal_pk, fid)
129
+ with self._lock:
130
+ # drop assemblies nobody finished in ten minutes
131
+ for k in [k for k, v in self._partials.items() if time.time() - v['at'] > 600]:
132
+ self._partials.pop(k, None)
133
+ parts = self._partials.get(key)
134
+ if parts is None:
135
+ mine = sum(1 for k in self._partials if k[0] == seal_pk)
136
+ if mine >= const.PARTIALS_PER_SENDER or len(self._partials) >= const.PARTIALS_MAX:
137
+ self._drop('fragment: too many unfinished envelopes', None)
138
+ return None
139
+ parts = self._partials[key] = Partial(n=n, parts={}, at=time.time())
140
+ if parts['n'] != n:
141
+ self._drop('fragment: count changed mid-envelope', None)
142
+ self._partials.pop(key, None)
143
+ return None
144
+ parts['parts'][i] = data
145
+ if len(parts['parts']) < n:
146
+ return None
147
+ self._partials.pop(key, None)
148
+ try:
149
+ return json.loads(''.join(parts['parts'][k] for k in range(n)))
150
+ except ValueError:
151
+ self._drop('reassembled fragments are not JSON', None)
152
+ return None
153
+
154
+
155
+ def receive(self: RelayRungShape, timeout: Optional[float] = None) -> Optional[Inbound]:
156
+ """The next unsolicited message, or None on timeout."""
157
+ try:
158
+ return self.inbox.get(timeout=timeout)
159
+ except queue.Empty:
160
+ return None
161
+
@@ -0,0 +1,104 @@
1
+ """The acknowledgment ledger: stored messages sent and not yet answered.
2
+
3
+ A relay's OK is its word about itself, not about delivery. Every stored
4
+ message asks for an ack (DIDComm `please_ack`) and is held here until one
5
+ arrives; `sweep()` turns silence from a peer that has since spoken into a
6
+ verdict the operator can read.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import queue
12
+ from typing import TYPE_CHECKING, Any, Optional, Sequence
13
+
14
+ from .. import const
15
+ from ..const import TransportError
16
+ from .types import AckVerdict, Verdict
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from .shape import RelayRungShape
21
+
22
+
23
+ class LedgerMixin:
24
+ def _ack(self: RelayRungShape, mid: str, peer: str) -> None:
25
+ """A pure acknowledgment: the empty message with an `ack` header."""
26
+ msg = {'id': 'ack-' + os.urandom(6).hex(), 'type': const.EMPTY_TYPE, 'thid': mid, 'ack': [mid], 'body': {}}
27
+ # Counted before the send and taken back if it raised, the way the
28
+ # relay face counts what it refuses. The peer's `sweep` clears on the
29
+ # ack itself rather than on this number, so nothing on the wire turns
30
+ # on the order - but health is read by another thread, and a caller
31
+ # that has seen the ack arrive and then reads 0 acks out has been told
32
+ # the wrong thing rather than told it late.
33
+ self.acks_out += 1
34
+ try:
35
+ self.send(msg, peer)
36
+ except TransportError as e:
37
+ self.acks_out -= 1
38
+ self._drop('ack not sent: %s' % e, None)
39
+
40
+ def _acker(self: RelayRungShape) -> None:
41
+ """Acks are sent off the relay thread, because a send publishes to
42
+ every relay with a timeout each and the subscription must not wait."""
43
+ while not self._stop.is_set():
44
+ try:
45
+ mid, peer = self._acks.get(timeout=0.5)
46
+ except queue.Empty:
47
+ continue
48
+ self._ack(mid, peer)
49
+
50
+ def _acknowledged(self: RelayRungShape, ids: Sequence[Any]) -> None:
51
+ for mid in ids:
52
+ if not isinstance(mid, str):
53
+ continue
54
+ with self._lock:
55
+ entry = self.awaiting.pop(mid, None)
56
+ if entry is None:
57
+ continue
58
+ self.acked[entry['type']] += 1
59
+ self.acks_in += 1
60
+ if self.ack_store is not None:
61
+ self.ack_store.drop_awaiting(mid)
62
+
63
+ def sweep(self: RelayRungShape, now: Optional[int] = None) -> list[AckVerdict]:
64
+ """Judge what is still unacknowledged, and return the new verdicts.
65
+
66
+ A message is undelivered when the peer has been heard from at least
67
+ ACK_SLACK after it was sent, that hearing is ACK_GRACE old, and no ack
68
+ has come: the peer was up, a relay serving the wrap would have handed
69
+ it over, and the peer said nothing about it. A peer not heard from is
70
+ not a verdict; it may simply be away. Past ACK_EXPIRE the relay would
71
+ not serve the wrap anyway, and the entry is dropped as expired.
72
+ """
73
+ now_val = int(now if now is not None else self.clock())
74
+ out: list[AckVerdict] = []
75
+ with self._lock:
76
+ for mid, e in list(self.awaiting.items()):
77
+ heard = self.heard.get(e['peer'])
78
+ if heard is not None and heard >= e['at'] + const.ACK_SLACK:
79
+ e['heard_at'] = heard if e['heard_at'] is None else e['heard_at']
80
+ verdict: Optional[Verdict] = None
81
+ if e['heard_at'] is not None and now_val >= e['heard_at'] + const.ACK_GRACE:
82
+ verdict = 'unacknowledged'
83
+ elif now_val >= e['at'] + const.ACK_EXPIRE:
84
+ verdict = 'expired'
85
+ if verdict is None:
86
+ continue
87
+ del self.awaiting[mid]
88
+ v = AckVerdict(peer=e['peer'], type=e['type'], at=e['at'], relays=e['relays'],
89
+ heard_at=e['heard_at'], mid=mid, verdict=verdict, at_verdict=now_val)
90
+ self.unacked.append(v)
91
+ out.append(v)
92
+ self._unacked_new.extend(out)
93
+ if self.ack_store is not None:
94
+ for v in out:
95
+ self.ack_store.drop_awaiting(v['mid'])
96
+ return out
97
+
98
+ def take_unacked(self: RelayRungShape) -> list[AckVerdict]:
99
+ """The verdicts since the last call, for the compartment that records them."""
100
+ with self._lock:
101
+ out: list[AckVerdict] = self._unacked_new
102
+ self._unacked_new = list[AckVerdict]()
103
+ return out
104
+
@@ -0,0 +1,121 @@
1
+ """The listening side of the relay rung: one subscription per relay for the
2
+ life of the node, with reconnects the operator can read about, and the
3
+ readiness check everything that is about to cause traffic waits on.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ import time
9
+ from typing import TYPE_CHECKING, Any, Optional
10
+
11
+ from ...protocols.nostr import client as relay
12
+ from .. import const
13
+ from . import wire
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from .shape import RelayRungShape
18
+
19
+
20
+ class ListenMixin:
21
+ def start(self: RelayRungShape, since: Optional[int] = None) -> Any:
22
+ # A wrap's created_at is skewed up to two days into the past, so a
23
+ # subscription that starts "now" misses most of them. Whatever the
24
+ # caller asks for, the filter reaches back at least that far.
25
+ floor = wire.since_floor(self.clock())
26
+ since_val = floor if since is None else min(since, floor)
27
+ for url in self.relays:
28
+ t = threading.Thread(target=self._listen, args=(url, since_val), daemon=True)
29
+ t.start()
30
+ self._threads.append(t)
31
+ t = threading.Thread(target=self._acker, daemon=True)
32
+ t.start()
33
+ self._threads.append(t)
34
+ return self
35
+
36
+ def stop(self: RelayRungShape) -> None:
37
+ self._stop.set()
38
+
39
+
40
+ def _listen(self: RelayRungShape, url: str, since: int) -> None:
41
+ """One relay's subscription, for the life of the node.
42
+
43
+ This used to swallow every error and retry once a second, silently,
44
+ with the same `since` it started with. A relay that was down for a
45
+ day was dialled eighty-six thousand times and nobody was told; one
46
+ that came back replayed everything since the node started. The
47
+ state is kept where the operator can read it, the retry backs off,
48
+ and each reconnect asks only for what the skew allowance requires.
49
+ """
50
+ h = self.health[url]
51
+ backoff = 1.0
52
+ first = True
53
+ while not self._stop.is_set():
54
+ if not first:
55
+ since = wire.since_floor(self.clock())
56
+ first = False
57
+ filt = wire.subscription(self.ks.node_pk, since)
58
+ def _on_late_eose(_sid: str) -> None:
59
+ h['confirmed'] = True
60
+ if h.get('last_error') and 'no EOSE in' in str(h['last_error']):
61
+ h['last_error'] = None
62
+
63
+ r = relay.Relay(url, timeout=5.0, auth_signer=self._sign_auth, on_eose=_on_late_eose)
64
+ try:
65
+ sid = r.connect().subscribe([filt], ready=True)
66
+ # `connected` is the socket; `confirmed` is the relay's own
67
+ # EOSE. They are reported apart because a subscription that
68
+ # was sent and never acknowledged still carries events, so
69
+ # tearing it down would be worse than saying so.
70
+ h['connected'] = True
71
+ h['confirmed'] = r.confirmed(sid)
72
+ h['since'] = self.clock()
73
+ h['last_error'] = (None if r.confirmed(sid)
74
+ else 'the subscription was not acknowledged: no EOSE in %.0fs' % 5.0)
75
+ backoff = 1.0
76
+ for wrap in r.events(sid):
77
+ if self._stop.is_set():
78
+ break
79
+ h['last_event_at'] = self.clock()
80
+ try:
81
+ self._deliver(wrap)
82
+ except Exception as e: # a bug in delivery drops one wrap, not the relay
83
+ self._drop('delivery failed: %s: %s' % (type(e).__name__, e),
84
+ wrap.get('id') if isinstance(wrap, dict) else None)
85
+ if not self._stop.is_set():
86
+ h['last_error'] = 'subscription ended'
87
+ except Exception as e:
88
+ h['last_error'] = '%s: %s' % (type(e).__name__, str(e)[:120])
89
+ finally:
90
+ r.close()
91
+ h['confirmed'] = False
92
+ if h['connected']:
93
+ h['connected'] = False
94
+ h['reconnects'] += 1
95
+ if not self._stop.is_set():
96
+ self._stop.wait(backoff)
97
+ backoff = min(backoff * 2, const.BACKOFF_MAX)
98
+
99
+
100
+ def listening(self: RelayRungShape) -> int:
101
+ """How many relays have acknowledged this node's own subscription."""
102
+ return sum(1 for h in self.health.values() if h['confirmed'])
103
+
104
+ def wait_listening(self: RelayRungShape, seconds: float = 10.0, at_least: int = 1) -> bool:
105
+ """Block until `at_least` relays have acknowledged the subscription.
106
+
107
+ `start()` returns as soon as the threads exist, which is not the same as
108
+ being reachable. Between the two, a live wrap addressed to this node is
109
+ fanned out to nobody and no relay keeps a copy, because kind 21059 is
110
+ ephemeral - the node never learns there was anything to miss. Anything
111
+ about to cause traffic waits here instead of sleeping and hoping: a
112
+ node starting up, or a test that is about to publish.
113
+
114
+ False if the wait ran out, which is a relay problem and is already on
115
+ the health of the relay that caused it.
116
+ """
117
+ deadline = time.time() + seconds
118
+ while time.time() < deadline and self.listening() < at_least:
119
+ time.sleep(0.05)
120
+ return self.listening() >= at_least
121
+
@@ -0,0 +1,181 @@
1
+ """Outbound side of the relay rung: address, encrypt, wrap, publish; and the
2
+ thread waiters request()/converse() use to wait for an answer.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import queue
7
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, cast
8
+
9
+ from fleetmesh_core.identity import didcomm, relaylist
10
+ from fleetmesh_core.identity.did import did_nostr
11
+ from ...protocols.nostr import client as relay
12
+ from .. import const
13
+ from ..const import Inbound, TransportError
14
+ from . import cooldown, wire
15
+ from .types import AwaitingEntry, OutboundMessage
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from .shape import RelayRungShape
20
+
21
+
22
+ class OutboundMixin:
23
+ def inbox_relays(self: RelayRungShape, to_pubkey: str) -> list[str]:
24
+ """Where a stored wrap for this peer goes: the relays its kind 10050
25
+ names (NIP-17), and this node's own when it has published none or
26
+ they cannot be read. The peer's inbox first, because that is where
27
+ it will look; ours as well, because a peer with no list reads its
28
+ descriptor relays and those are usually ours."""
29
+ now = self.clock()
30
+ held = self._dm_relays.get(to_pubkey)
31
+ if held is not None and now - held[1] < const.DM_RELAYS_TTL:
32
+ theirs = held[0]
33
+ else:
34
+ theirs = []
35
+ if self.relays:
36
+ try:
37
+ found = relay.query_all(self.relays, [relaylist.filter_for(to_pubkey)], self.timeout)
38
+ newest = max(found, key=lambda e: int(e.get('created_at', 0)), default=None)
39
+ theirs = relaylist.dm_relays(newest) if newest is not None else []
40
+ except Exception:
41
+ theirs = []
42
+ self._dm_relays[to_pubkey] = (theirs, now)
43
+ return theirs + [r for r in self.relays if r not in theirs]
44
+
45
+
46
+ def send(self: RelayRungShape,
47
+ message: Mapping[str, Any],
48
+ to_pubkey: str,
49
+ anonymous: bool = False,
50
+ stored: bool = False,
51
+ ) -> dict[str, tuple[bool, str]]:
52
+ """Encrypt, wrap and publish. Returns {relay: (accepted, note)}.
53
+
54
+ `stored` sends kind 1059, which relays keep, for the messages the
55
+ specification says must survive the recipient being offline: a grant
56
+ amendment, a complaint notice, a window close. Everything else is
57
+ kind 21059 and gone if nobody is listening, which is the point.
58
+ """
59
+ their = self.peers.agreement(to_pubkey)
60
+ if their is None:
61
+ raise TransportError('no live descriptor for %s; cannot address an envelope' % to_pubkey[:16])
62
+ msg_dict = cast(OutboundMessage, dict(message)) # the caller's message, addressed below
63
+ msg_dict.setdefault('from', self.ks.did)
64
+ msg_dict.setdefault('to', [did_nostr(to_pubkey)])
65
+ msg_dict.setdefault('created_time', self.clock())
66
+ if stored and isinstance(msg_dict.get('id'), str):
67
+ # "" is DIDComm's shorthand for this message. A pure ack never
68
+ # asks for one, so this cannot start a circle.
69
+ msg_dict['please_ack'] = ['']
70
+ kid = did_nostr(to_pubkey) + '#agree'
71
+ sender = None if anonymous else (self.ks.kid('agreement'), self.ks.agreement_priv)
72
+ jwe = didcomm.encrypt(msg_dict, [(kid, their)], sender=sender)
73
+ held = stored and isinstance(msg_dict.get('id'), str)
74
+ if held:
75
+ # Held before the first relay is asked, not after the last one
76
+ # answers: a peer on the first relay acknowledges in tens of
77
+ # milliseconds, and an entry registered after the second relay's
78
+ # OK was being registered after the ack it waited for had come
79
+ # and gone. Which relays took it is filled in below.
80
+ entry = AwaitingEntry(peer=to_pubkey, type=str(msg_dict.get('type', ''))[-40:],
81
+ at=self.clock(), relays=[], heard_at=None)
82
+ with self._lock:
83
+ self.awaiting[msg_dict['id']] = entry
84
+ results: dict[str, tuple[bool, str]] = {}
85
+ wanted = self.inbox_relays(to_pubkey) if stored else self.relays
86
+ targets = self.cooldown.open(wanted)
87
+ for url in wanted:
88
+ cooling = self.cooldown.holding(url)
89
+ if cooling is not None:
90
+ # Not asked, so not refused; the result says why it was skipped.
91
+ results[url] = (False, 'cooling until %d: %s' % (cooling['until'], cooling['why']))
92
+ for payload in wire.fragments(jwe):
93
+ wrap = wire.make_wrap(payload, self.ks.node_sk, to_pubkey, self.clock(), stored)
94
+ took = relay.publish_all(targets, wrap, self.timeout, auth_signer=self._sign_auth)
95
+ for url, r in took.items():
96
+ if r[0]:
97
+ self.cooldown.accepted(url)
98
+ else:
99
+ cooling = self.cooldown.refused(url, r[1] or '')
100
+ if cooling is not None:
101
+ self._drop('relay cooled: %s until %d (strike %d) %s'
102
+ % (url, cooling['until'], cooling['strikes'], r[1]), wrap['id'])
103
+ if url not in results:
104
+ results[url] = r
105
+ else:
106
+ prev_ok, prev_note = results[url]
107
+ curr_ok, curr_note = r
108
+ results[url] = (prev_ok and curr_ok, curr_note if not curr_ok else prev_note)
109
+ refused = {u: r[1] or 'no reason' for u, r in took.items()
110
+ if not r[0] and not cooldown.is_throttle(r[1] or '')}
111
+ if refused and not any(r[0] for r in took.values()):
112
+ # No relay took this fragment, so the envelope never leaves and
113
+ # nothing else in the protocol will ever say so: the peer times
114
+ # out and the session is merely aborted. One relay of several
115
+ # refusing while another accepts is that relay's weather, kept
116
+ # in its health row; a throttle is recorded above as the
117
+ # cooling it caused.
118
+ self._drop('publish refused: %s' % '; '.join('%s %s' % kv for kv in sorted(refused.items())), wrap['id'])
119
+ elif refused:
120
+ for url, why in refused.items():
121
+ self.faults.record('relay flaked: %s %s' % (url, why), wrap['id'])
122
+ if held:
123
+ accepted = sorted(u for u, r in results.items() if r[0])
124
+ with self._lock:
125
+ still = self.awaiting.get(msg_dict['id'])
126
+ if still is not None:
127
+ if accepted:
128
+ still['relays'] = accepted
129
+ else:
130
+ # Nowhere took it, so it never left; the refused
131
+ # publish above is the record and there is nothing
132
+ # to wait for.
133
+ del self.awaiting[msg_dict['id']]
134
+ still = None
135
+ if still is not None and self.ack_store is not None:
136
+ self.ack_store.put_awaiting(msg_dict['id'], to_pubkey, still['type'], still['at'], accepted)
137
+ return results
138
+
139
+
140
+ def request(self: RelayRungShape,
141
+ message: Mapping[str, Any],
142
+ to_pubkey: str,
143
+ timeout: float = 30.0,
144
+ anonymous: bool = False,
145
+ ) -> Optional[Inbound]:
146
+ """Send and wait for the reply threaded on this message's id."""
147
+ return self.converse(message, to_pubkey, message['id'], timeout, anonymous)
148
+
149
+ def converse(self: RelayRungShape,
150
+ message: Mapping[str, Any],
151
+ to_pubkey: str,
152
+ thid: str,
153
+ timeout: float = 30.0,
154
+ anonymous: bool = False,
155
+ ) -> Optional[Inbound]:
156
+ """Send and wait for the next message on an existing thread. A sync
157
+ session keeps one thread open across many exchanges, so the waiter is
158
+ keyed by the thread and not by the message just sent."""
159
+ q = self.listen(thid)
160
+ try:
161
+ self.send(message, to_pubkey, anonymous=anonymous)
162
+ try:
163
+ return q.get(timeout=timeout)
164
+ except queue.Empty:
165
+ return None
166
+ finally:
167
+ self.unlisten(thid)
168
+
169
+ def listen(self: RelayRungShape, thid: str) -> queue.Queue[Inbound]:
170
+ """Route messages on this thread to a private queue instead of the inbox."""
171
+ with self._lock:
172
+ q = self._waiting.get(thid)
173
+ if q is None:
174
+ q = self._waiting[thid] = queue.Queue()
175
+ return q
176
+
177
+ def unlisten(self: RelayRungShape, thid: str) -> None:
178
+ with self._lock:
179
+ self._waiting.pop(thid, None)
180
+
181
+ # ---- inbound -----------------------------------------------------------
@@ -0,0 +1,71 @@
1
+ """What the relay rung's mixins may assume about `self`.
2
+
3
+ Declared once so outbound.py, listen.py, inbound.py and ledger.py can call
4
+ each other's methods and read the shared state without inheriting from one
5
+ another. core.RelayRung is the one class that satisfies it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import queue
10
+ import threading
11
+ from typing import Any, Callable, Counter, Deque, Mapping, Optional, Protocol
12
+
13
+ from ..const import Inbound
14
+ from ...interfaces import NostrEvent, RelayHealthRow
15
+ from ..ladder import FaultLog
16
+ from .cooldown import RelayCooldown
17
+ from .types import AckVerdict, AwaitingEntry, Partial
18
+
19
+
20
+ class RelayRungShape(Protocol):
21
+ ks: Any
22
+ peers: Any
23
+ relays: list[str]
24
+ clock: Callable[[], int]
25
+ timeout: float
26
+ seen_max: int
27
+ inbox: queue.Queue[Inbound]
28
+ _waiting: dict[str, queue.Queue[Inbound]]
29
+ _lock: threading.Lock
30
+ _stop: threading.Event
31
+ _threads: list[threading.Thread]
32
+ health: dict[str, RelayHealthRow]
33
+ faults: FaultLog
34
+ cooldown: RelayCooldown
35
+ # ledger
36
+ ack_store: Optional[Any]
37
+ awaiting: dict[str, AwaitingEntry]
38
+ heard: dict[str, int]
39
+ acked: Counter[str]
40
+ acks_in: int
41
+ acks_out: int
42
+ unacked: Deque[AckVerdict]
43
+ _unacked_new: list[AckVerdict]
44
+ _acks: queue.Queue[tuple[str, str]]
45
+ # inbound
46
+ seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]]
47
+ wraps_in: Counter[Optional[int]]
48
+ wraps_seen_again: Counter[Optional[int]]
49
+ _seen: set[str]
50
+ _seen_order: Deque[str]
51
+ _partials: dict[tuple[str, str], Partial]
52
+ # outbound
53
+ _dm_relays: dict[str, tuple[list[str], int]]
54
+
55
+ def _drop(self, reason: str, wrap_id: Optional[str]) -> None: ...
56
+ def _sign_auth(self, relay_url: str, challenge: str) -> NostrEvent: ...
57
+ def send(self, message: Any, to_pubkey: str, anonymous: bool = False,
58
+ stored: bool = False) -> dict[str, tuple[bool, str]]: ...
59
+ def listen(self, thid: str) -> queue.Queue[Inbound]: ...
60
+ def unlisten(self, thid: str) -> None: ...
61
+ def _deliver(self, wrap: Any) -> None: ...
62
+ def _acknowledged(self, ids: Any) -> None: ...
63
+ def _ack(self, mid: str, peer: str) -> None: ...
64
+ def listening(self) -> int: ...
65
+ def _remember(self, wid: str, kind: Optional[int] = None) -> bool: ...
66
+ def _reassemble(self, frag: Mapping[str, Any], seal_pk: str) -> Optional[Any]: ...
67
+ def inbox_relays(self, to_pubkey: str) -> list[str]: ...
68
+ def converse(self, message: Any, to_pubkey: str, thid: str, timeout: float = 30.0,
69
+ anonymous: bool = False) -> Optional[Inbound]: ...
70
+ def _listen(self, url: str, since: int) -> None: ...
71
+ def _acker(self) -> None: ...