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
node/metrics/face.py ADDED
@@ -0,0 +1,110 @@
1
+ """The relay face's metrics, as declarations.
2
+
3
+ `relayserver.render_metrics` built these eight by hand, with the names, HELP
4
+ and TYPE typed inline and every reading wrapped in `hasattr` plus a bare
5
+ `except: pass`. They were the only metrics the node exposed.
6
+
7
+ Declaring them changes nothing an operator scrapes, and that is the point.
8
+ Every exposed name and every TYPE is reproduced exactly, because a metric
9
+ name is an interface: a dashboard, an alert rule and a recording rule are all
10
+ written against it, and renaming one to suit a convention breaks them
11
+ silently. Two of these are not what their names suggest - `fleetmesh_blobs_total`
12
+ is a gauge despite the counter suffix, and `fleetmesh_events_stored_total`
13
+ counts what is in the store now and can go down - and both are kept as they
14
+ are rather than corrected. The place to fix an exposition name is a release
15
+ note, not a refactor.
16
+
17
+ What does change: the readings come through `Monitors`, so a sampler that
18
+ raises is counted rather than swallowed by `except: pass`, and the face's
19
+ numbers sit in the same census as every compartment's.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Callable, Mapping
24
+
25
+ from . import COUNTER, GAUGE, Metric
26
+
27
+
28
+ def _try(fn: Callable[[], Any], default: Any = 0) -> Any:
29
+ """A reading, or the default.
30
+
31
+ The old code caught everything and moved on, which is why a store that
32
+ could not be counted reported zero and nobody knew. `Monitors.read`
33
+ counts a sampler that raises, so a sampler here lets a real error through
34
+ and only guards the shape - an object that does not implement `count` at
35
+ all, which is a configuration and not a fault.
36
+ """
37
+ try:
38
+ return fn()
39
+ except (AttributeError, TypeError):
40
+ return default
41
+
42
+
43
+ def face_metrics(face: Any) -> tuple[Metric, ...]:
44
+ """The eight the relay face has always exposed, bound to this server.
45
+
46
+ `face` is the RelayServer. The samplers close over it rather than being
47
+ handed values, so each scrape reads the live number.
48
+ """
49
+ def stored() -> int:
50
+ return int(_try(lambda: face.store.count()))
51
+
52
+ def conns() -> int:
53
+ with face._lock:
54
+ return len(face._conns)
55
+
56
+ def subs() -> int:
57
+ with face._lock:
58
+ return sum(len(s) for s in face._subs.values())
59
+
60
+ def blob_count() -> int:
61
+ if face.blob_store is None:
62
+ return 0
63
+ return int(_try(lambda: face.blob_store.count()))
64
+
65
+ def blob_bytes() -> int:
66
+ if face.blob_store is None:
67
+ return 0
68
+ return int(_try(lambda: face.blob_store.total_bytes(), 0) or 0)
69
+
70
+ def by_tier() -> Mapping[str, int]:
71
+ """Peers per trust tier, which is the one labelled metric here."""
72
+ out: dict[str, int] = {}
73
+ if face.grants is None:
74
+ return out
75
+ for g in _try(lambda: list(face.grants.list()), []):
76
+ payload = g.get('payload')
77
+ t = payload.get('tier') if isinstance(payload, dict) else g.get('tier', 'unknown')
78
+ out[str(t)] = out.get(str(t), 0) + 1
79
+ return out
80
+
81
+ return (
82
+ # Named `_total` and typed counter since it was written, though it
83
+ # counts what is in the store now and can fall. Kept as it is: the
84
+ # name is what alerts are written against.
85
+ Metric(name='events_stored', description='Total events stored in local store.',
86
+ kind=COUNTER, sample=stored, tags=('face', 'store')),
87
+ Metric(name='active_connections', description='Current active WebSocket connections.',
88
+ kind=GAUGE, sample=conns, tags=('face', 'connections')),
89
+ Metric(name='active_subscriptions', description='Current active event subscriptions.',
90
+ kind=GAUGE, sample=subs, tags=('face', 'subscriptions')),
91
+ Metric(name='events_accepted', description='Total valid events accepted through relay face.',
92
+ kind=COUNTER, sample=lambda: int(getattr(face, 'accepted', 0)),
93
+ tags=('face', 'events')),
94
+ Metric(name='events_refused_future',
95
+ description='Total events refused due to future timestamps.',
96
+ kind=COUNTER, sample=lambda: int(getattr(face, 'refused_future', 0)),
97
+ tags=('face', 'events', 'refusals')),
98
+ # A gauge wearing a counter's suffix, preserved deliberately.
99
+ Metric(name='blobs_total', description='Total content-addressed blobs in Blossom CAS.',
100
+ kind=GAUGE, sample=blob_count, tags=('face', 'blobs')),
101
+ Metric(name='blobs_bytes_total',
102
+ description='Total size in bytes of blobs in Blossom CAS.',
103
+ kind=GAUGE, sample=blob_bytes, tags=('face', 'blobs')),
104
+ Metric(name='peers_by_tier',
105
+ description='Number of peers categorized by trust tier.',
106
+ kind=GAUGE, labels=('tier',), sample=by_tier, tags=('face', 'peering')),
107
+ )
108
+
109
+
110
+ __all__ = ['face_metrics']
@@ -0,0 +1,21 @@
1
+ """Architectural planes of the Fleetmesh node.
2
+
3
+ As defined in fleetmesh.org/docs/implementing.md and enforced mechanically
4
+ by check-implementing.py:
5
+ 1. identity (discovery, announce, presence, linked)
6
+ 2. store (sync, retention, blob_wire, blob_replication)
7
+ 3. peering (trust, strain, peering, budget, capacity, reporter)
8
+ 4. transport (link, lan, p2p, mediation, bridge)
9
+ 5. services (gossip, compute, compute_wire, settle, paidfetch, sidecars, dvm, nsite)
10
+ 6. operations (scheduler, updates)
11
+ """
12
+ from . import identity, store, peering, transport, services, operations
13
+
14
+ __all__ = [
15
+ 'identity',
16
+ 'store',
17
+ 'peering',
18
+ 'transport',
19
+ 'services',
20
+ 'operations',
21
+ ]
@@ -0,0 +1,20 @@
1
+ """Identity plane: discovery, announce, presence, linked."""
2
+ from . import announce, discovery, linked, presence
3
+ from .announce.compartment import Announce
4
+ from .discovery.compartment import Discovery, DiscoveryConfig
5
+ from .linked.compartment import LinkedConfig, LinkedFace
6
+ from .presence.compartment import Presence, PresenceConfig
7
+
8
+ __all__ = [
9
+ 'Announce',
10
+ 'Discovery',
11
+ 'DiscoveryConfig',
12
+ 'LinkedConfig',
13
+ 'LinkedFace',
14
+ 'Presence',
15
+ 'PresenceConfig',
16
+ 'announce',
17
+ 'discovery',
18
+ 'linked',
19
+ 'presence',
20
+ ]
@@ -0,0 +1,9 @@
1
+ """The gossipsub announce topic, reached through a sidecar, as a compartment.
2
+
3
+ One-module compartment: everything is in compartment.py.
4
+ """
5
+ from .compartment import Announce
6
+ from .types import AnnounceStateReport, AnnounceTotals
7
+
8
+ __all__ = ['Announce', 'AnnounceStateReport', 'AnnounceTotals']
9
+
@@ -0,0 +1,447 @@
1
+ """The gossipsub announce topic, reached through a sidecar, as a compartment.
2
+
3
+ Bridge 2 in the specification: a node republishes its descriptor on
4
+ `/fleetmesh/announce/1` so that nodes which have never shared a relay with it
5
+ hear about it anyway. The relay path (bridge 1, `discovery_wire.py`) always
6
+ works; this one reaches further.
7
+
8
+ It is a sidecar and not a library because of what a library would cost. `fleetmesh_core/`
9
+ is standard library only and `node/` depends on `cryptography` and nothing
10
+ else - `ws.py` is a hand-rolled RFC 6455 for exactly that reason. Installing
11
+ the one maintained Python libp2p pulls in sixty-three packages, eight of them
12
+ compiled; it does not install on Python 3.14 at all; and it is trio where this
13
+ node is threads. A second implementer reading the specification should not
14
+ inherit that, so the libp2p stack lives in its own process with its own
15
+ dependencies and this file speaks to it over a unix socket in the node's
16
+ directory, one JSON object per line, the same shape `control.py` already uses.
17
+
18
+ The node never starts the sidecar and never needs it. No socket is not an
19
+ error, it is a node with one discovery bridge instead of two, and it is said
20
+ once rather than once a tick.
21
+
22
+ **Nothing here trusts the sidecar.** Every row it hands over goes through
23
+ `admit` - the same function the sidecar ran before gossipsub propagated
24
+ anything - and then through `peers.learn`, exactly as a descriptor fetched
25
+ from a relay does. The work floor is re-applied here because it is this
26
+ node's policy and not the sidecar's.
27
+
28
+ The one check `admit` needs from the sidecar is which libp2p peer originated
29
+ the message, because a Multikey is compared against it. A sidecar that lies
30
+ about the originator can only make this node reject a descriptor it would
31
+ have accepted; it cannot make it accept one, because the Multikey is inside
32
+ an event the node key signed.
33
+
34
+ The pilot bridge (node/sidecar_bridge): the link to the sidecar, saying it
35
+ once each way, the version gate, the `announce.sidecar` and `announce.probe`
36
+ commands and the restart all come from `Bridge`, as they will for every other
37
+ sidecar. What is left here is what is announce's own - the topic, `admit`,
38
+ what a drain may teach this node.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ import os
45
+ import tempfile
46
+ from typing import Any, Callable, Optional, cast
47
+
48
+ from node import channels as ch_mod
49
+ from node.protocols.nostr import descriptor as descriptor_mod
50
+ from node.compartment import lifecycle as lifecycle_mod
51
+ from node import metrics as mx_mod
52
+ from node.compartment import stores as st
53
+ from node.config import AnnounceConfig
54
+ from fleetmesh_core.crypto import keys
55
+ from fleetmesh_core.identity import identity
56
+ from fleetmesh_core.wire.interfaces import NostrEvent
57
+ from node.compartment import Command, Need
58
+ from node.compartment.sidecar import UP, Bridge, Sidecar, SidecarError, jsonline_call
59
+
60
+ SOCK: str = 'announce.sock'
61
+ PROTOCOL: str = 'fleetmesh-announce/1'
62
+
63
+
64
+ from .types import AnnounceStateReport
65
+ TOPIC: str = '/fleetmesh/announce/1'
66
+ MAX_LINE: int = 1 << 20
67
+ # What one drain may bring back. A topic is a flood channel with no grant in
68
+ # front of it, so the bound is here rather than in the sender's good manners.
69
+ DRAIN_MAX: int = 64
70
+ KIND: int = 11801
71
+ # What one descriptor may weigh on the topic. A kind 11801 carrying every tag
72
+ # the specification allows is a couple of kilobytes; ten times that is a
73
+ # payload nobody needs and everybody would carry.
74
+ PAYLOAD_MAX: int = 20000
75
+ # The proof-of-work floor this node requires of a descriptor heard on the
76
+ # topic. Local policy, not a protocol constant: two nodes with different
77
+ # floors interoperate, the stricter one simply propagates less. Zero means
78
+ # the descriptor's own declared target is the only bar, which is what a node
79
+ # on a private mesh wants.
80
+ POW_BITS: int = 0
81
+
82
+
83
+ def socket_path(directory: str, configured: Optional[str] = None) -> str:
84
+ """Where the sidecar listens. Same derivation as the control socket, for
85
+ the same reason: macOS caps a unix socket path near a hundred bytes and a
86
+ test's temporary directory is longer than that."""
87
+ if configured:
88
+ return os.path.abspath(os.path.expanduser(configured))
89
+ real = os.path.realpath(directory)
90
+ p = os.path.join(real, SOCK)
91
+ if len(p.encode()) < 100:
92
+ return p
93
+ return os.path.join(tempfile.gettempdir(),
94
+ 'fleetmesh-announce-%s.sock' % hashlib.sha256(real.encode()).hexdigest()[:16])
95
+
96
+
97
+ def transport_multikey(desc: NostrEvent) -> Optional[str]:
98
+ """The `key transport` Multikey a descriptor declares, or None."""
99
+ for t in desc.get('tags', []):
100
+ if t and t[0] == 'key' and len(t) > 2 and t[1] == 'transport':
101
+ return t[2]
102
+ return None
103
+
104
+
105
+ def binds(desc: NostrEvent, ed25519_hex: str) -> bool:
106
+ """Whether the descriptor names this Ed25519 key as its transport key.
107
+
108
+ The gossipsub message was signed under StrictSign by the *originator*, and
109
+ a forwarder does not re-sign, so this is the originator's key and the
110
+ descriptor must be the originator's own. A descriptor relayed by a third
111
+ node is still valid on a flood topic - the check is against what the
112
+ descriptor claims, never against who handed it over.
113
+ """
114
+ mk = transport_multikey(desc)
115
+ if not mk or not ed25519_hex:
116
+ return False
117
+ try:
118
+ kind, raw = keys.parse_multikey(mk)
119
+ except ValueError:
120
+ return False
121
+ return kind == 'transport' and raw.hex() == ed25519_hex.lower()
122
+
123
+
124
+ def admit(
125
+ payload: Any,
126
+ origin: Optional[str],
127
+ now: int,
128
+ pow_bits: int = 0,
129
+ payload_max: int = PAYLOAD_MAX,
130
+ ) -> tuple[Optional[NostrEvent], Optional[str]]:
131
+ """Whether the topic should carry this. (descriptor, None) or (None, why).
132
+
133
+ The whole of the defence on a flood channel with no grant in front of it,
134
+ and the one place it is written. The sidecar calls this before gossipsub
135
+ propagates anything, and the node calls it again on what the sidecar hands
136
+ over, because a separate process is not a reason to believe a descriptor.
137
+
138
+ Ordered cheapest first: a size that needs no parse, a parse, a kind, a
139
+ signature, the descriptor rules, the work, and last the binding, which is
140
+ the only check that needs to know who sent it.
141
+
142
+ `origin` is the *originator's* Ed25519 key - gossipsub StrictSign's
143
+ `from`, not the peer who handed the message over. A forwarder does not
144
+ re-sign, and a descriptor relayed by a third node is still valid here.
145
+ """
146
+ if isinstance(payload, (bytes, bytearray)):
147
+ if len(payload) > payload_max:
148
+ return None, 'payload over %d bytes' % payload_max
149
+ try:
150
+ desc = json.loads(bytes(payload).decode('utf-8'))
151
+ except (ValueError, UnicodeDecodeError):
152
+ return None, 'payload is not JSON'
153
+ else:
154
+ desc = payload
155
+ if not isinstance(desc, dict):
156
+ return None, 'not an event'
157
+ if desc.get('kind') != KIND:
158
+ return None, 'not a kind %d' % KIND
159
+ if not descriptor_mod.genuine(desc):
160
+ return None, 'signature does not verify'
161
+ problems = identity.validate_descriptor(desc, now=now, digest=descriptor_mod.DIGEST)
162
+ if problems:
163
+ return None, 'descriptor: %s' % str(problems[0])[:48]
164
+ if pow_bits and identity.pow_bits(str(desc.get('id') or '')) < pow_bits:
165
+ return None, 'below %d bits of work' % pow_bits
166
+ desc_ev = cast(NostrEvent, desc)
167
+ if not binds(desc_ev, origin or ''):
168
+ return None, 'transport key does not bind'
169
+ return desc_ev, None
170
+
171
+
172
+ def call(path: str, cmd: str, timeout: float = 5.0, **args: Any) -> Any:
173
+ """One request, one reply, one connection. Raises SidecarError for all of
174
+ no sidecar, a sidecar that will not answer, and a sidecar that says no.
175
+
176
+ For a caller outside a node (a script, the sidecar's own tests). Inside
177
+ one, the sidecar is reached through the bridge - `Announce.call` - which
178
+ also moves the link and checks the protocol the sidecar answered in.
179
+ """
180
+ return jsonline_call(path, cmd, timeout=timeout, **args)[0]
181
+
182
+
183
+ from .types import AnnounceTotals
184
+
185
+
186
+ class Announce(Bridge):
187
+
188
+ name: str = 'announce'
189
+ reads: tuple[str, ...] = ('announce',)
190
+ config_cls = AnnounceConfig
191
+
192
+ SIDECAR = Sidecar(
193
+ name='announce', protocol=PROTOCOL,
194
+ upstream='py-libp2p gossipsub on %s' % TOPIC,
195
+ standalone='fleetmesh-announce NODE_DIR [--bootstrap MULTIADDR]',
196
+ probe='status')
197
+ CONSEQUENCE = 'this node is discoverable over relays only'
198
+
199
+ # Declared by the bridge from SIDECAR, under these names, with their
200
+ # counters (announce_sidecar_reachable / _unreachable / _incompatible).
201
+ SIDECAR_REACHABLE: str = 'announce.sidecar.reachable'
202
+ SIDECAR_UNREACHABLE: str = 'announce.sidecar.unreachable'
203
+ SIDECAR_INCOMPATIBLE: str = 'announce.sidecar.incompatible'
204
+ DESCRIPTOR_PUBLISHED: str = 'announce.descriptor.published'
205
+ PEERS_LEARNED: str = 'announce.peers.learned'
206
+
207
+ CHANNELS = (
208
+ ch_mod.Channel(
209
+ name=DESCRIPTOR_PUBLISHED,
210
+ description='The node descriptor was republished to the gossipsub announce topic.',
211
+ level=ch_mod.INFO,
212
+ wire=lambda id, topic: {'id': id, 'topic': topic},
213
+ say='published {id} on {topic}',
214
+ tags=('announce', 'descriptor')),
215
+ ch_mod.Channel(
216
+ name=PEERS_LEARNED,
217
+ description='New descriptors heard on the announce topic were verified and learned.',
218
+ level=ch_mod.INFO,
219
+ wire=lambda learned, heard, topic: {'learned': learned, 'heard': heard, 'topic': topic},
220
+ say='{learned} new peers learned from {heard} heard on {topic}',
221
+ tags=('announce', 'peers')),
222
+ )
223
+
224
+ METRICS = (
225
+ mx_mod.Metric(
226
+ name='announce_published',
227
+ description='Times this node published its descriptor on the announce topic.',
228
+ kind=mx_mod.COUNTER, counts=DESCRIPTOR_PUBLISHED,
229
+ tags=('announce', 'descriptor')),
230
+ mx_mod.Metric(
231
+ name='announce_peers_learned',
232
+ description='Times new peers were learned from the announce topic.',
233
+ kind=mx_mod.COUNTER, counts=PEERS_LEARNED,
234
+ tags=('announce', 'peers')),
235
+ )
236
+
237
+ COMMANDS = (
238
+ Command(
239
+ key='show',
240
+ name='Announce Status',
241
+ description='the announce topic: whether the sidecar is there, what it has carried, '
242
+ 'and what this node refused off the topic',
243
+ tags=('announce', 'sidecar', 'status'),
244
+ parameters=(),
245
+ permission='announce/show',
246
+ process=('sidecar',),
247
+ run='state',
248
+ reply='{enabled, socket, connected, topic, peer_id, mesh_peers, published, '
249
+ 'heard, learned, sidecar}',
250
+ reads=('totals',),
251
+ ),
252
+ )
253
+
254
+ # What the topic has carried since this node first ran - not since its
255
+ # last upgrade: SNAPSHOT, so the lifecycle hands it to the next process.
256
+ # Not `published`: the next process's link starts unknown and the sidecar
257
+ # must hear the descriptor again (it may have restarted too).
258
+ STORES = (
259
+ st.Store(key='totals', description='descriptors carried up the topic, learned, and echoed back',
260
+ kind=st.COUNTERS, shape=AnnounceTotals, persist=st.SNAPSHOT),
261
+ )
262
+ # Restartable like every bridge; snapshot v1 is the shape of `totals`.
263
+ LIFECYCLE = lifecycle_mod.Lifecycle(restart=True, snapshot=1)
264
+ NEEDS = (
265
+ Need('peers', 'peers'),
266
+ Need('directory', 'dir'),
267
+ Need('timeout', 'timeout'),
268
+ Need('descriptor', 'descriptor'),
269
+ )
270
+
271
+ def __init__(
272
+ self,
273
+ peers: Any,
274
+ directory: str,
275
+ clock: Optional[Callable[[], int]] = None,
276
+ timeout: float = 5.0,
277
+ node: Optional[Any] = None,
278
+ descriptor: Optional[Callable[[], Optional[NostrEvent]]] = None,
279
+ ) -> None:
280
+ super().__init__(node, clock)
281
+ # The node's own descriptor, read each publish through a getter
282
+ # because it is built after this compartment is.
283
+ self.descriptor: Callable[[], Optional[NostrEvent]] = descriptor or (lambda: getattr(node, 'descriptor', None))
284
+ self.peers: Any = peers
285
+ self.dir: str = directory
286
+ # Read once: a node that heard itself would otherwise cache itself.
287
+ self.node_pk: str = getattr(getattr(node, 'ks', None), 'node_pk', '') or ''
288
+ self.timeout: float = timeout
289
+ self.published: Optional[str] = None # the descriptor id last handed to the sidecar
290
+ self.totals: st.Counters # built and bound by the base, from STORES
291
+ self.mesh_peers: int = 0
292
+ self.peer_id: Optional[str] = None
293
+
294
+ # The totals by name, as they were read before they were a store.
295
+ @property
296
+ def heard(self) -> int:
297
+ """Descriptors the sidecar carried up."""
298
+ return self.totals['heard']
299
+
300
+ @heard.setter
301
+ def heard(self, n: int) -> None:
302
+ self.totals['heard'] = n
303
+
304
+ @property
305
+ def learned(self) -> int:
306
+ """Of those, the ones this node did not already hold."""
307
+ return self.totals['learned']
308
+
309
+ @learned.setter
310
+ def learned(self, n: int) -> None:
311
+ self.totals['learned'] = n
312
+
313
+ @property
314
+ def echoed(self) -> int:
315
+ """And the ones that were this node's own publish returning."""
316
+ return self.totals['echoed']
317
+
318
+ @echoed.setter
319
+ def echoed(self, n: int) -> None:
320
+ self.totals['echoed'] = n
321
+
322
+ # ---- the bridge -----------------------------------------------------------
323
+ def endpoint(self) -> str:
324
+ return self.path
325
+
326
+ def wanted(self) -> bool:
327
+ return self.enabled
328
+
329
+ def forget(self) -> None:
330
+ # A sidecar that went away, or a bridge restarted, must hear the
331
+ # descriptor again when the link comes back.
332
+ self.published = None
333
+
334
+ # ---- configuration --------------------------------------------------------
335
+ @property
336
+ def enabled(self) -> bool:
337
+ return self.settings().enabled
338
+
339
+ @property
340
+ def path(self) -> str:
341
+ return socket_path(self.dir, self.settings().socket)
342
+
343
+ @property
344
+ def pow_bits(self) -> int:
345
+ return self.settings().pow_bits
346
+
347
+ # ---- the tick -------------------------------------------------------------
348
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
349
+ if not self.enabled:
350
+ return []
351
+ did: list[tuple[Any, ...]] = []
352
+ try:
353
+ did += self._publish(now)
354
+ did += self._drain(now)
355
+ except SidecarError:
356
+ # The bridge has moved the link, said so once and refused it;
357
+ # a missing sidecar costs this tick its gossip and nothing else.
358
+ return did
359
+ return did
360
+
361
+ def _publish(self, now: int) -> list[tuple[Any, ...]]:
362
+ """The node's own descriptor on the topic, republished only when it changes."""
363
+ desc = self.descriptor()
364
+ if not isinstance(desc, dict) or not desc.get('id'):
365
+ return []
366
+ if self.published == desc['id']:
367
+ return []
368
+ self.call('publish', descriptor=desc)
369
+ self.published = desc['id']
370
+ self.say(self.DESCRIPTOR_PUBLISHED, desc['id'][:12] + '…', TOPIC)
371
+ return [('announce', desc['id'][:12] + '…', 'published on ' + TOPIC)]
372
+
373
+ def _drain(self, now: int) -> list[tuple[Any, ...]]:
374
+ """Everything the sidecar heard since the last tick, checked here again."""
375
+ res = self.call('drain', max=DRAIN_MAX)
376
+ if not isinstance(res, dict):
377
+ bad = 'drain answered something that is not an object'
378
+ self._down(bad, now)
379
+ raise SidecarError(bad)
380
+ self.peer_id = res.get('peer_id') or self.peer_id
381
+ try:
382
+ self.mesh_peers = int(res.get('mesh_peers') or 0)
383
+ except (TypeError, ValueError):
384
+ self.mesh_peers = 0
385
+ learned = 0
386
+ for row in (res.get('heard') or [])[:DRAIN_MAX]:
387
+ if not isinstance(row, dict):
388
+ continue
389
+ self.totals.incr('heard')
390
+ payload = row.get('descriptor')
391
+ origin = str(row.get('from') or '')
392
+ # The same check the sidecar ran before propagating it. Run again
393
+ # because a separate process is not a reason to believe an event,
394
+ # and because the work floor is this node's policy and not its.
395
+ desc, why = admit(payload, origin, now, pow_bits=self.pow_bits)
396
+ if desc is None:
397
+ subject = (payload.get('pubkey') if isinstance(payload, dict) else None) or None
398
+ self.refusals.refuse('heard', str(why), subject=subject,
399
+ detail={'from': origin[:16]}, at=now)
400
+ continue
401
+ if self.node_pk and desc['pubkey'] == self.node_pk:
402
+ # A node hears its own publish: gossipsub runs the validator on
403
+ # the way out, so the descriptor this node just put on the topic
404
+ # comes straight back. Learning it would put this node in its own
405
+ # peer cache, which is where `known()` is read from. Not a
406
+ # refusal - it is the topic working.
407
+ self.totals.incr('echoed')
408
+ continue
409
+ if self.peers.learn(desc):
410
+ learned += 1
411
+ # A descriptor this node already holds at the same age is ordinary
412
+ # on a flood topic and is not a refusal: the same descriptor
413
+ # arrives many times over, which is what the topic is for.
414
+ self.totals.incr('learned', learned)
415
+ for reason, n in sorted((res.get('dropped') or {}).items()):
416
+ # What the sidecar's own validator refused before propagating it.
417
+ # Counted here so `meshnode refusals` shows one topic, not two.
418
+ self.refusals.refuse('sidecar', str(reason)[:64], detail={'count': n}, at=now)
419
+ if learned:
420
+ self.say(self.PEERS_LEARNED, learned, len(res.get('heard') or []), TOPIC)
421
+ return [('announce', '%d heard' % len(res.get('heard') or []), '%d new' % learned)] if learned else []
422
+
423
+ # ---- being watched --------------------------------------------------------
424
+ def state(self) -> AnnounceStateReport:
425
+ return {
426
+ 'enabled': self.enabled,
427
+ 'socket': self.path,
428
+ 'connected': self.link == UP,
429
+ 'topic': TOPIC,
430
+ 'peer_id': self.peer_id,
431
+ 'mesh_peers': self.mesh_peers,
432
+ 'pow_bits': self.pow_bits,
433
+ 'published': self.published,
434
+ 'heard': self.heard,
435
+ 'learned': self.learned,
436
+ 'echoed': self.echoed,
437
+ }
438
+
439
+ def trouble(self) -> list[str]:
440
+ # Down or incompatible is the bridge's to say, the same way for every
441
+ # sidecar; an empty topic is announce's own.
442
+ out = super().trouble()
443
+ if out or not self.wanted():
444
+ return out
445
+ if self.link == UP and not self.mesh_peers:
446
+ return ['on %s with no gossipsub peers: nothing hears this node there' % TOPIC]
447
+ return []
@@ -0,0 +1,26 @@
1
+ """Types and records for the Announce compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional, TypedDict
5
+
6
+
7
+ class AnnounceStateReport(TypedDict, total=False):
8
+ """The report Announce.state() gives of itself."""
9
+ enabled: bool
10
+ socket: Optional[str]
11
+ connected: bool
12
+ topic: str
13
+ peer_id: Optional[str]
14
+ mesh_peers: int
15
+ pow_bits: int
16
+ published: Optional[str | int]
17
+ heard: int
18
+ learned: int
19
+ echoed: int
20
+
21
+
22
+ class AnnounceTotals(TypedDict):
23
+ """What the announce topic has carried: one counter each."""
24
+ heard: int # descriptors the sidecar carried up
25
+ learned: int # of those, the ones this node did not already hold
26
+ echoed: int # and the ones that were this node's own publish returning
@@ -0,0 +1,9 @@
1
+ """What this node tells the mesh it is and what it holds, as a compartment.
2
+
3
+ One-module compartment: everything is in compartment.py.
4
+ """
5
+ from .compartment import Discovery
6
+ from .types import DiscoveryStateReport
7
+
8
+ __all__ = ['Discovery', 'DiscoveryStateReport']
9
+