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/app/plugins.py ADDED
@@ -0,0 +1,76 @@
1
+ """Plugin loading and sidecar manifest helpers for NodeApp."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ from typing import Any, Sequence
6
+
7
+ from .. import compartment as compartment_mod
8
+ from ..planes.services.sidecars import compartment as sidecars_mod
9
+
10
+ __all__ = [
11
+ 'load_plugins',
12
+ 'plugin_sidecars',
13
+ ]
14
+
15
+
16
+ def load_plugins(
17
+ specs: Sequence[Any],
18
+ manifest: Sequence[type[compartment_mod.Compartment]],
19
+ ) -> tuple[type[compartment_mod.Compartment], ...]:
20
+ """The manifest with each configured plugin class spliced in after its anchor.
21
+
22
+ A plugin ref is `package.module:Class`; the class must be a Compartment
23
+ with a name no manifest compartment uses. `after` names the compartment
24
+ it ticks right after (default: the end, before `updates` if that is
25
+ present, since updates reports on everything before it). Every failure
26
+ is a ValueError naming the ref: a node would rather not start than start
27
+ with a plugin it could not load, which is also why loading happens here
28
+ and not lazily in a tick.
29
+
30
+ This imports operator-declared code into the node's process. It is a
31
+ trust decision the config file states; see PluginsConfig.
32
+ """
33
+ out = list(manifest)
34
+ names: set[str] = {str(c.name) for c in out}
35
+ for spec in specs:
36
+ mod_name, _, cls_name = spec.ref.partition(':')
37
+ try:
38
+ mod = importlib.import_module(mod_name)
39
+ except ImportError as e:
40
+ raise ValueError('plugin %s: cannot import %s (%s)' % (spec.ref, mod_name, e)) from e
41
+ cls = getattr(mod, cls_name, None)
42
+ if not (isinstance(cls, type) and issubclass(cls, compartment_mod.Compartment)):
43
+ raise ValueError('plugin %s: %s is not a Compartment class' % (spec.ref, cls_name))
44
+ pname = getattr(cls, 'name', None)
45
+ if not pname:
46
+ raise ValueError('plugin %s declares no name' % spec.ref)
47
+ if pname in names:
48
+ raise ValueError('plugin %s: a compartment named %r already exists' % (spec.ref, pname))
49
+ if spec.after is None:
50
+ idx = len(out)
51
+ if out and out[-1].name == 'updates':
52
+ idx -= 1
53
+ else:
54
+ if spec.after not in names:
55
+ raise ValueError('plugin %s: after=%r names no compartment (have: %s)'
56
+ % (spec.ref, spec.after, ' '.join(sorted(names))))
57
+ idx = next(i for i, c in enumerate(out) if c.name == spec.after) + 1
58
+ out.insert(idx, cls)
59
+ names.add(pname)
60
+ return tuple(out)
61
+
62
+
63
+ def plugin_sidecars(cls: type[Any]) -> list[sidecars_mod.Sidecar]:
64
+ """The sidecar manifests a plugin class declares, parsed and linted
65
+ (`sidecars = ({...}, '@/etc/x.json')`). Every failure is a ValueError
66
+ naming the plugin, so the same message serves startup and `plugin-check`."""
67
+ out: list[sidecars_mod.Sidecar] = []
68
+ for i, raw in enumerate(getattr(cls, 'sidecars', ()) or ()):
69
+ try:
70
+ if isinstance(raw, str) and raw.startswith('@'):
71
+ out.append(sidecars_mod.manifest_mod.load_file(raw[1:], origin=sidecars_mod.RESERVED_ORIGIN))
72
+ else:
73
+ out.append(sidecars_mod.manifest_mod.parse(raw, origin=sidecars_mod.RESERVED_ORIGIN))
74
+ except (sidecars_mod.ManifestError, OSError, ValueError) as e:
75
+ raise ValueError('plugin %s: sidecars[%d]: %s' % (getattr(cls, 'name', cls.__name__), i, e)) from e
76
+ return out
node/app/router.py ADDED
@@ -0,0 +1,83 @@
1
+ """Declarative protocol message router for DIDComm PIURI dispatch.
2
+
3
+ Routes inbound envelopes to registered subsystem handlers based on their
4
+ PIURI (Protocol Identification URI) prefix or exact message type.
5
+ Standard library only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import collections
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
11
+
12
+ HandlerFunc = Callable[[Any], Optional[Tuple[Any, ...]]]
13
+
14
+
15
+ class ProtocolRouter:
16
+ """DIDComm protocol router mapping message types to subsystem handlers."""
17
+
18
+ def __init__(self) -> None:
19
+ self._exact_routes: Dict[str, HandlerFunc] = {}
20
+ self._prefix_routes: Dict[str, HandlerFunc] = {}
21
+ self._catchalls: List[HandlerFunc] = []
22
+ self._dispatch_counts: collections.Counter[str] = collections.Counter()
23
+
24
+ def register_exact(self, msg_type: str, handler: HandlerFunc) -> None:
25
+ """Register a handler for an exact DIDComm message type."""
26
+ self._exact_routes[msg_type] = handler
27
+
28
+ def register(self, prefix: str, handler: HandlerFunc) -> None:
29
+ """Register a handler for a PIURI prefix (e.g. 'https://fleetmesh.org/sync/1.0')."""
30
+ self._prefix_routes[prefix] = handler
31
+
32
+ def register_catchall(self, handler: HandlerFunc) -> None:
33
+ """Register a fallback handler invoked when no prefix matches or handler yields None."""
34
+ self._catchalls.append(handler)
35
+
36
+ def unregister(self, prefix_or_type: str) -> None:
37
+ """Remove a previously registered route."""
38
+ self._exact_routes.pop(prefix_or_type, None)
39
+ self._prefix_routes.pop(prefix_or_type, None)
40
+
41
+ def route_for(self, msg_type: str) -> Optional[HandlerFunc]:
42
+ """Find the most specific registered handler for a message type."""
43
+ if not msg_type:
44
+ return None
45
+ if msg_type in self._exact_routes:
46
+ return self._exact_routes[msg_type]
47
+ # Match longest prefix first
48
+ for prefix in sorted(self._prefix_routes.keys(), key=len, reverse=True):
49
+ if msg_type.startswith(prefix):
50
+ return self._prefix_routes[prefix]
51
+ return None
52
+
53
+ def dispatch(self, inbound: Any) -> Optional[Tuple[Any, ...]]:
54
+ """Dispatch an inbound envelope to the matching handler.
55
+
56
+ Returns the handler result (typically a status tuple), or None if unhandled.
57
+ """
58
+ msg = getattr(inbound, 'message', inbound)
59
+ msg_type = msg.get('type', '') if isinstance(msg, dict) else ''
60
+
61
+ handler = self.route_for(msg_type)
62
+ if handler is not None:
63
+ res = handler(inbound)
64
+ if res is not None:
65
+ self._dispatch_counts[msg_type] += 1
66
+ return res
67
+
68
+ for catchall in self._catchalls:
69
+ res = catchall(inbound)
70
+ if res is not None:
71
+ self._dispatch_counts[msg_type or 'catchall'] += 1
72
+ return res
73
+
74
+ return None
75
+
76
+ def registered_prefixes(self) -> List[str]:
77
+ """Return all registered PIURI prefixes."""
78
+ return sorted(self._prefix_routes.keys())
79
+
80
+ @property
81
+ def counts(self) -> collections.Counter[str]:
82
+ """Dispatch count by message type."""
83
+ return self._dispatch_counts
node/app/socket.py ADDED
@@ -0,0 +1,431 @@
1
+ """The control socket: how an operator asks a running node to do something.
2
+
3
+ A running node owns its directory and its relay face port, so a second
4
+ process cannot stand one up beside it to act on its behalf. Declarative
5
+ state - peers, trust, coverage - goes through the config file, which the
6
+ daemon re-reads every tick. An action - pull now, terminate a peering, ask
7
+ a peer about a third node, force a tick - needs the daemon itself, and this
8
+ is the channel: a unix socket in the node directory, one JSON object per
9
+ line each way, readable and writable by the node's own user and nobody else.
10
+
11
+ The protocol is named and versioned: `meshnode-control/1`. A request is
12
+ {"cmd": name, "args": {...}}; a reply is {"ok": true, "protocol": ...,
13
+ "result": ...} or {"ok": false, "protocol": ..., "error": reason}. The
14
+ commands, their arguments and what each answers are served by the socket
15
+ itself under `commands`, from the same table the daemon dispatches on, so
16
+ what it says about itself cannot drift from what it does. A field is only
17
+ ever added to a reply within a major version; a field removed or renamed is
18
+ `meshnode-control/2`.
19
+
20
+ The socket path is derived from the directory on both sides, so nothing
21
+ has to be looked up. macOS limits a unix socket path to about a hundred
22
+ bytes, and a test's temporary directory is longer than that, so a directory
23
+ whose path will not fit gets a socket in the temp directory named by the
24
+ hash of the real path. An operator's /var/lib/meshnode fits.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import inspect
30
+ import json
31
+ import os
32
+ import socket
33
+ import tempfile
34
+ import threading
35
+ import time
36
+ from typing import Any, Callable, Iterator, Mapping
37
+
38
+ SOCK: str = 'control.sock'
39
+ PROTOCOL: str = 'meshnode-control/1'
40
+ MAX_LINE: int = 1 << 20
41
+
42
+
43
+ class Stream(object):
44
+ """A dispatcher returns one of these for a verb that answers more than
45
+ once. The server sends the ack line, then one line per envelope `pull`
46
+ yields until the client closes or `pull` returns None past `idle`
47
+ seconds of silence with the client gone. `close` runs once at the end.
48
+ A streaming connection does not count against the request semaphore:
49
+ a reader that holds on for an hour must not starve `health`."""
50
+
51
+ def __init__(self, ack: Any, pull: Callable[[float], Any], close: Callable[[], None] | None = None) -> None:
52
+ self.ack: Any = ack
53
+ self.pull: Callable[[float], Any] = pull
54
+ self.close: Callable[[], None] | None = close
55
+
56
+
57
+ class ControlError(Exception):
58
+ pass
59
+
60
+
61
+ def socket_path(directory: str) -> str:
62
+ real = os.path.realpath(directory)
63
+ p = os.path.join(real, SOCK)
64
+ if len(p.encode()) < 100:
65
+ return p
66
+ return os.path.join(tempfile.gettempdir(),
67
+ 'meshnode-%s.sock' % hashlib.sha256(real.encode()).hexdigest()[:16])
68
+
69
+
70
+ def _recv_line(conn: socket.socket) -> bytes:
71
+ buf = b''
72
+ while not buf.endswith(b'\n'):
73
+ chunk = conn.recv(65536)
74
+ if not chunk:
75
+ break
76
+ buf += chunk
77
+ if len(buf) > MAX_LINE:
78
+ raise ControlError('request too large')
79
+ return buf
80
+
81
+
82
+ def _reader_alive(conn: socket.socket) -> bool:
83
+ try:
84
+ conn.setblocking(False)
85
+ try:
86
+ conn.recv(1, socket.MSG_PEEK)
87
+ finally:
88
+ conn.setblocking(True)
89
+ except BlockingIOError:
90
+ return True
91
+ except OSError:
92
+ return False
93
+ return False # EOF, or the reader spoke: either way this stream is over
94
+
95
+
96
+ class Server(object):
97
+ """Serves dispatch(cmd, args) -> result over the socket, one request per connection."""
98
+
99
+ def __init__(self, directory: str, dispatch: Callable[..., Any], max_conns: int = 16,
100
+ max_streams: int = 32) -> None:
101
+ self.path: str = socket_path(directory)
102
+ self.dispatch: Callable[..., Any] = dispatch
103
+ # Whether the dispatcher wants to know who is calling. Decided once,
104
+ # here, rather than guessed per request: a dispatcher written before
105
+ # actors existed takes two arguments and keeps working, and one that
106
+ # wants attribution says so by accepting `actor`.
107
+ try:
108
+ self._wants_actor: bool = 'actor' in inspect.signature(dispatch).parameters
109
+ except (TypeError, ValueError):
110
+ self._wants_actor = False
111
+ self._stop: threading.Event = threading.Event()
112
+ self.sock: socket.socket | None = None
113
+ self.served: int = 0
114
+ self.streams: int = 0 # live streaming connections
115
+ self.max_conns: int = max_conns
116
+ self.max_streams: int = max_streams
117
+ self.errors: list[str] = []
118
+ self._sem: threading.BoundedSemaphore = threading.BoundedSemaphore(max_conns)
119
+ self._slock = threading.Lock()
120
+ self._inode: int | None = None
121
+
122
+ def start(self) -> Server:
123
+ try:
124
+ os.unlink(self.path)
125
+ except OSError:
126
+ pass
127
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
128
+ old_umask = os.umask(0o177)
129
+ try:
130
+ self.sock.bind(self.path)
131
+ os.chmod(self.path, 0o600)
132
+ finally:
133
+ os.umask(old_umask)
134
+ # Remember which inode is ours, so stop() never removes a socket a
135
+ # newer node bound at the same path after this one lost it.
136
+ try:
137
+ self._inode = os.stat(self.path).st_ino
138
+ except OSError:
139
+ self._inode = None
140
+ # 0600 on the socket means nothing if another user can rename the
141
+ # directory entry out from under it before the operator connects.
142
+ try:
143
+ mode = os.stat(os.path.dirname(self.path)).st_mode
144
+ except OSError:
145
+ mode = 0
146
+ if mode & 0o022:
147
+ self.errors.append('the directory holding %s is group/world-writable; another user could replace the socket'
148
+ % self.path)
149
+ self.sock.listen(max(64, self.max_conns * 2))
150
+ self.sock.settimeout(0.5)
151
+ threading.Thread(target=self._loop, daemon=True).start()
152
+ return self
153
+
154
+ def _refuse_busy(self, conn: socket.socket) -> None:
155
+ """Told off its own thread: a slow client must not stall accept()."""
156
+ try:
157
+ conn.settimeout(5.0)
158
+ reply = {'ok': False, 'protocol': PROTOCOL, 'error': 'too many concurrent control requests'}
159
+ conn.sendall((json.dumps(reply) + '\n').encode('utf-8'))
160
+ except OSError:
161
+ pass
162
+ finally:
163
+ conn.close()
164
+
165
+ def _loop(self) -> None:
166
+ while not self._stop.is_set():
167
+ try:
168
+ assert self.sock is not None
169
+ conn, _ = self.sock.accept()
170
+ except socket.timeout:
171
+ continue
172
+ except OSError:
173
+ break
174
+ if not self._sem.acquire(blocking=False):
175
+ threading.Thread(target=self._refuse_busy, args=(conn,), daemon=True).start()
176
+ continue
177
+ threading.Thread(target=self._one_wrapped, args=(conn,), daemon=True).start()
178
+
179
+ def _one_wrapped(self, conn: socket.socket) -> None:
180
+ released = False
181
+ try:
182
+ stream = self._one(conn)
183
+ if stream is not None:
184
+ # Hand the slot back before the long part: the stream has its own cap.
185
+ self._sem.release()
186
+ released = True
187
+ self._stream(conn, stream)
188
+ finally:
189
+ if not released:
190
+ self._sem.release()
191
+
192
+ def _stream(self, conn: socket.socket, stream: Stream) -> None:
193
+ # The slot was taken in _one, when the cap was checked; this only
194
+ # hands it back.
195
+ try:
196
+ conn.settimeout(None)
197
+ conn.setblocking(True)
198
+ while not self._stop.is_set():
199
+ env = stream.pull(1.0)
200
+ if env is None:
201
+ # Nothing to send. A reader that went away shows as EOF on
202
+ # a non-blocking peek (a live one that has nothing to say
203
+ # raises EWOULDBLOCK); the protocol has the reader say
204
+ # nothing after the request, so anything else it sends is
205
+ # also taken as "done".
206
+ if not _reader_alive(conn):
207
+ break
208
+ continue
209
+ try:
210
+ conn.sendall((json.dumps(env, default=str) + '\n').encode('utf-8'))
211
+ except OSError:
212
+ break
213
+ finally:
214
+ with self._slock:
215
+ self.streams -= 1
216
+ if stream.close is not None:
217
+ try:
218
+ stream.close()
219
+ except Exception:
220
+ pass
221
+ try:
222
+ conn.close()
223
+ except OSError:
224
+ pass
225
+
226
+ def _one(self, conn: socket.socket) -> Stream | None:
227
+ """Answer one request. Returns the Stream to keep serving, or None when the connection is done."""
228
+ stream: Stream | None = None
229
+ raw_stream: Stream | None = None
230
+ reserved = False # a stream slot taken below, handed to _stream or back
231
+ try:
232
+ conn.settimeout(30)
233
+ try:
234
+ try:
235
+ req = json.loads(_recv_line(conn).decode('utf-8'))
236
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
237
+ # The client's fault, not a bug in a command: say so and
238
+ # keep it out of the error ring an operator reads.
239
+ raise ControlError('a request is one line of JSON: %s' % e)
240
+ if not isinstance(req, dict) or not isinstance(req.get('cmd'), str):
241
+ raise ControlError('a request is {"cmd": ..., "args": {...}}; `commands` lists them')
242
+ if not isinstance(req.get('args') or {}, dict):
243
+ raise ControlError('args is an object')
244
+ # Who is asking, established at the boundary. SO_PEERCRED
245
+ # can only be read off the live connection, so this is the
246
+ # one place it exists; by the time a command runs the socket
247
+ # is a detail nobody downstream can recover.
248
+ #
249
+ # The claimed operator comes from the request and is exactly
250
+ # that - claimed. `Actor.assurance` and the proof ladder say
251
+ # what was actually established, and `peercred` names a unix
252
+ # user rather than a DID, so it corroborates and never
253
+ # identifies. Nothing here checks an ability: see the note in
254
+ # node/config/change.py on why enforcing one against a
255
+ # 0600 socket would be a control that protects nothing.
256
+ if self._wants_actor:
257
+ result = self.dispatch(req['cmd'], req.get('args') or {},
258
+ actor=actor_of(conn, req))
259
+ else:
260
+ result = self.dispatch(req['cmd'], req.get('args') or {})
261
+ if isinstance(result, Stream):
262
+ # Check the cap and take the slot in one step, before the
263
+ # ack goes out. The count used to rise only once _stream
264
+ # began, after the ack, so a reader holding its ack was
265
+ # not counted yet, and a second request in that gap passed
266
+ # the check: two streams over a cap of one. CI hit it under
267
+ # a parallel suite (audit 2026-10-15 follow-up).
268
+ with self._slock:
269
+ full = self.streams >= self.max_streams
270
+ if not full:
271
+ self.streams += 1
272
+ reserved = True
273
+ if full:
274
+ if result.close is not None:
275
+ result.close()
276
+ raise ControlError('too many open streams (%d); close one' % self.max_streams)
277
+ stream = result
278
+ raw_stream = result
279
+ result = result.ack
280
+ reply: Mapping[str, Any] = {'ok': True, 'protocol': PROTOCOL, 'result': result}
281
+ except ControlError as e:
282
+ reply = {'ok': False, 'protocol': PROTOCOL, 'error': str(e)}
283
+ except Exception as e: # a bug in one command answers one request
284
+ if len(self.errors) >= 200:
285
+ self.errors.pop(0)
286
+ self.errors.append('%s: %s' % (type(e).__name__, e))
287
+ reply = {'ok': False, 'protocol': PROTOCOL, 'error': '%s: %s' % (type(e).__name__, e)}
288
+ self.served += 1
289
+ conn.sendall((json.dumps(reply, default=str) + '\n').encode('utf-8'))
290
+ except OSError:
291
+ stream = None
292
+ finally:
293
+ if stream is None:
294
+ if reserved: # the ack never reached the reader: no stream to serve
295
+ with self._slock:
296
+ self.streams -= 1
297
+ if raw_stream is not None and raw_stream.close is not None:
298
+ raw_stream.close()
299
+ conn.close()
300
+ return stream
301
+
302
+ def stop(self) -> None:
303
+ self._stop.set()
304
+ try:
305
+ if self.sock is not None:
306
+ self.sock.close()
307
+ except (OSError, AttributeError):
308
+ pass
309
+ # Only remove the socket this server bound. A node that lost the
310
+ # path to a newer one (a stale-socket replacement, a restart racing
311
+ # a slow shutdown) must not make the live node unreachable on its
312
+ # way out.
313
+ try:
314
+ if self._inode is not None and os.stat(self.path).st_ino == self._inode:
315
+ os.unlink(self.path)
316
+ except OSError:
317
+ pass
318
+
319
+ def state(self) -> Mapping[str, Any]:
320
+ """What `health` says about this socket."""
321
+ with self._slock:
322
+ streams = self.streams
323
+ return {'path': self.path, 'served': self.served, 'streams': streams,
324
+ 'errors': list(self.errors[-5:])}
325
+
326
+
327
+ def stream(directory: str, cmd: str, timeout: float | None = None, **args: Any) -> Iterator[Any]:
328
+ """Ask for a streaming verb and yield each envelope as it arrives. The
329
+ first value yielded is the ack (the request's own result); then one per
330
+ line until the node closes or the caller stops iterating. `timeout` is
331
+ how long to wait for the next line, None forever."""
332
+ path = socket_path(directory)
333
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
334
+ s.settimeout(timeout if timeout is not None else 90.0)
335
+ try:
336
+ s.connect(path)
337
+ except OSError as e:
338
+ s.close()
339
+ raise ControlError('no node is running in %s (%s)' % (directory, e.strerror or e))
340
+ try:
341
+ s.sendall((json.dumps({'cmd': cmd, 'args': args}) + '\n').encode('utf-8'))
342
+ # The ack and the first envelopes may arrive in one read; keep the remainder.
343
+ buf = b''
344
+ while b'\n' not in buf:
345
+ chunk = s.recv(65536)
346
+ if not chunk:
347
+ raise ControlError('the node closed the socket without answering')
348
+ buf += chunk
349
+ if len(buf) > MAX_LINE:
350
+ raise ControlError('reply too large')
351
+ raw, buf = buf.split(b'\n', 1)
352
+ reply = json.loads(raw.decode('utf-8'))
353
+ if not reply.get('ok'):
354
+ raise ControlError(reply.get('error') or 'refused')
355
+ yield reply.get('result')
356
+ s.settimeout(timeout)
357
+ while True:
358
+ while b'\n' in buf:
359
+ line, buf = buf.split(b'\n', 1)
360
+ if line:
361
+ yield json.loads(line.decode('utf-8'))
362
+ try:
363
+ chunk = s.recv(65536)
364
+ except socket.timeout:
365
+ raise ControlError('no event within %.0fs' % (timeout or 0))
366
+ if not chunk:
367
+ return
368
+ buf += chunk
369
+ finally:
370
+ s.close()
371
+
372
+
373
+ def running(directory: str) -> bool:
374
+ return os.path.exists(socket_path(directory))
375
+
376
+
377
+ def call(directory: str, cmd: str, timeout: float = 90.0, request: Mapping[str, Any] | None = None, **args: Any) -> Any:
378
+ """Ask the node running in `directory` to do one thing. Raises ControlError
379
+ when there is no node to ask, or when the node says no.
380
+
381
+ `timeout` is how long this client waits. A request argument that shares
382
+ the name (pull's own `timeout`) goes in `request`, which is merged over
383
+ `args`; before that, `meshnode pull --timeout` never reached the daemon."""
384
+ path = socket_path(directory)
385
+ if request:
386
+ args = {**args, **request}
387
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
388
+ s.settimeout(timeout)
389
+ try:
390
+ s.connect(path)
391
+ except OSError as e:
392
+ s.close()
393
+ raise ControlError('no node is running in %s (%s)' % (directory, e.strerror or e))
394
+ try:
395
+ s.sendall((json.dumps({'cmd': cmd, 'args': args}) + '\n').encode('utf-8'))
396
+ raw = _recv_line(s)
397
+ except socket.timeout:
398
+ raise ControlError('the node did not answer within %.0fs' % timeout)
399
+ finally:
400
+ s.close()
401
+ if not raw:
402
+ raise ControlError('the node closed the socket without answering')
403
+ reply = json.loads(raw.decode('utf-8'))
404
+ if not reply.get('ok'):
405
+ raise ControlError(reply.get('error') or 'refused')
406
+ return reply.get('result')
407
+
408
+
409
+ def actor_of(conn: 'socket.socket', req: Mapping[str, Any]) -> Any:
410
+ """Who made this request, with every rung that answered recorded.
411
+
412
+ Built here because SO_PEERCRED can only be read off a live connection.
413
+ The operator and session are whatever the caller sent - a claim, marked
414
+ as one - and the peercred rung corroborates that a local user made the
415
+ call without ever claiming to identify a DID.
416
+
417
+ Imported lazily: node/control.py is reached by the CLI as well as the
418
+ daemon, and the config package pulls in the whole typed config tree.
419
+ """
420
+ from ..config import change as change_mod # noqa: PLC0415
421
+ claimed = str(req.get('operator') or '')
422
+ proofs = [change_mod.asserted(claimed)] if claimed else []
423
+ cred = change_mod.peercred_of(conn)
424
+ if cred is not None:
425
+ proofs.append(cred)
426
+ return change_mod.Actor(
427
+ operator=claimed,
428
+ session=str(req.get('session') or ''),
429
+ tool=str(req.get('tool') or change_mod.LOCAL_TOOL),
430
+ at=int(time.time()),
431
+ proofs=tuple(proofs))
node/app/systemd.py ADDED
@@ -0,0 +1,36 @@
1
+ """sd_notify: the one line the node says to systemd.
2
+
3
+ Separate because it touches a socket and nothing else here does; the
4
+ config module should not need `socket`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import socket
10
+
11
+
12
+ def sd_notify(state: str) -> bool:
13
+ """Send state notification to systemd's service manager via NOTIFY_SOCKET.
14
+
15
+ Zero-dependency Unix datagram implementation supporting filesystem and abstract sockets.
16
+ Returns True if sent successfully, False otherwise.
17
+ """
18
+ notify_socket = os.environ.get('NOTIFY_SOCKET')
19
+ if not notify_socket:
20
+ return False
21
+
22
+ if notify_socket.startswith('@'):
23
+ notify_socket = '\0' + notify_socket[1:]
24
+
25
+ try:
26
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
27
+ try:
28
+ payload = state.encode('utf-8')
29
+ if not payload.endswith(b'\n'):
30
+ payload += b'\n'
31
+ sock.sendto(payload, notify_socket)
32
+ return True
33
+ finally:
34
+ sock.close()
35
+ except Exception:
36
+ return False