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,731 @@
1
+ """Local Multi-Node Cluster & Chaos Testnet Engine.
2
+
3
+ Enables developers and operators to bootstrap, peer, inspect, and stress-test
4
+ multi-node MeshNode topologies with simulated network chaos:
5
+ 1. Topologies: mesh, star (hub-spoke), ring, and line.
6
+ 2. Isolated local node directories with distinct identities, SQLite stores, and face ports.
7
+ 3. Automated peering proposal, mutual grant exchange, and sync orchestration.
8
+ 4. Chaos Simulator:
9
+ - Network partitions and healing.
10
+ - Configurable packet loss injection.
11
+ - Adversarial bad-peer injection (duplicate flood, forged hops, active silence).
12
+ - Automated S1..S4 severity verification and Kind 30802 dispute tracking.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import random
19
+ import tempfile
20
+ import threading
21
+ import time
22
+ from typing import Any, Callable, Mapping, Optional, Sequence
23
+
24
+ from fleetmesh_core.peering import badpeer
25
+ from fleetmesh_core.wire.interfaces import NostrEvent
26
+ from .. import app as app_mod, keystore
27
+ from ..protocols.nostr import relayserver
28
+ from ..storage import grants
29
+ from ..protocols.nostr import client as relay
30
+ from .fips_daemon import ClusterFipsDaemon
31
+
32
+ TOPOLOGIES: tuple[str, ...] = ('mesh', 'star', 'ring', 'line')
33
+
34
+
35
+ def generate_topology_edges(node_count: int, topology: str = 'mesh') -> list[tuple[int, int]]:
36
+ """Compute pairwise peering edges (a, b) for N nodes given a topology."""
37
+ if node_count < 2:
38
+ return []
39
+ edges: list[tuple[int, int]] = []
40
+ if topology == 'mesh':
41
+ for i in range(node_count):
42
+ for j in range(i + 1, node_count):
43
+ edges.append((i, j))
44
+ elif topology == 'star':
45
+ # Node 0 is the central hub/anchor; nodes 1..N-1 peer with node 0
46
+ for i in range(1, node_count):
47
+ edges.append((0, i))
48
+ elif topology == 'ring':
49
+ for i in range(node_count):
50
+ edges.append((i, (i + 1) % node_count))
51
+ elif topology == 'line':
52
+ for i in range(node_count - 1):
53
+ edges.append((i, i + 1))
54
+ else:
55
+ raise ValueError(f"unknown topology {topology!r}; expected one of {TOPOLOGIES}")
56
+ return edges
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Chaos Simulator
61
+ # ---------------------------------------------------------------------------
62
+
63
+ class ChaosSimulator:
64
+ """Network chaos injection for a running LocalCluster."""
65
+
66
+ def __init__(self, cluster: LocalCluster) -> None:
67
+ self.cluster: LocalCluster = cluster
68
+ self._lock: threading.RLock = threading.RLock()
69
+ self.partitions: list[tuple[set[str], set[str], Optional[str]]] = [] # list of (pubkeys_a, pubkeys_b, rung)
70
+ self.directed_partitions: list[tuple[set[str], set[str], Optional[str]]] = [] # list of (from_pks, to_pks, rung)
71
+ self.loss_rates: dict[str, float] = {} # pubkey -> drop probability [0.0, 1.0]
72
+ self.latencies: dict[tuple[str, Optional[str]], tuple[float, float]] = {} # (src_pk, dst_pk) -> (lat, jit)
73
+ self.adversarial_nodes: dict[str, Mapping[str, Any]] = {} # pubkey -> offence config
74
+ self._original_sends: dict[str, Callable[..., Any]] = {}
75
+
76
+ def partition(
77
+ self,
78
+ group_a: Sequence[int | str],
79
+ group_b: Sequence[int | str],
80
+ rung: Optional[str] = None,
81
+ ) -> None:
82
+ """Cut network connectivity between two groups of nodes (symmetric)."""
83
+ with self._lock:
84
+ pks_a = {self.cluster.resolve_node_pubkey(x) for x in group_a}
85
+ pks_b = {self.cluster.resolve_node_pubkey(x) for x in group_b}
86
+ self.partitions.append((pks_a, pks_b, rung))
87
+ self._ensure_interceptors()
88
+
89
+ def directed_partition(
90
+ self,
91
+ from_nodes: Sequence[int | str],
92
+ to_nodes: Sequence[int | str],
93
+ rung: Optional[str] = None,
94
+ ) -> None:
95
+ """Cut directed network connectivity from from_nodes to to_nodes (asymmetric)."""
96
+ with self._lock:
97
+ pks_from = {self.cluster.resolve_node_pubkey(x) for x in from_nodes}
98
+ pks_to = {self.cluster.resolve_node_pubkey(x) for x in to_nodes}
99
+ self.directed_partitions.append((pks_from, pks_to, rung))
100
+ self._ensure_interceptors()
101
+
102
+ def heal(self) -> None:
103
+ """Remove all network partitions and restore normal connectivity."""
104
+ with self._lock:
105
+ self.partitions.clear()
106
+ self.directed_partitions.clear()
107
+
108
+ def set_packet_loss(self, node: int | str, loss_rate: float) -> None:
109
+ """Simulate envelope drop probability for a specific node."""
110
+ with self._lock:
111
+ pk = self.cluster.resolve_node_pubkey(node)
112
+ if loss_rate <= 0.0:
113
+ self.loss_rates.pop(pk, None)
114
+ else:
115
+ self.loss_rates[pk] = min(1.0, float(loss_rate))
116
+ self._ensure_interceptors()
117
+
118
+ def set_latency(
119
+ self,
120
+ node: int | str,
121
+ target: Optional[int | str] = None,
122
+ latency_s: float = 0.0,
123
+ jitter_s: float = 0.0,
124
+ ) -> None:
125
+ """Inject latency and jitter (in seconds) for outgoing transmissions."""
126
+ with self._lock:
127
+ pk = self.cluster.resolve_node_pubkey(node)
128
+ target_pk = self.cluster.resolve_node_pubkey(target) if target is not None else None
129
+ key = (pk, target_pk)
130
+ if latency_s <= 0.0 and jitter_s <= 0.0:
131
+ self.latencies.pop(key, None)
132
+ else:
133
+ self.latencies[key] = (max(0.0, float(latency_s)), max(0.0, float(jitter_s)))
134
+ self._ensure_interceptors()
135
+
136
+ def clear_latency(
137
+ self,
138
+ node: Optional[int | str] = None,
139
+ target: Optional[int | str] = None,
140
+ ) -> None:
141
+ """Remove latency settings for a specific node, link, or all nodes."""
142
+ with self._lock:
143
+ if node is None and target is None:
144
+ self.latencies.clear()
145
+ else:
146
+ pk = self.cluster.resolve_node_pubkey(node) if node is not None else None
147
+ target_pk = self.cluster.resolve_node_pubkey(target) if target is not None else None
148
+ if pk is not None:
149
+ self.latencies.pop((pk, target_pk), None)
150
+ if target_pk is None:
151
+ for k in list(self.latencies.keys()):
152
+ if k[0] == pk:
153
+ self.latencies.pop(k, None)
154
+
155
+ def get_delay(self, pk_from: str, pk_to: str) -> float:
156
+ with self._lock:
157
+ spec = self.latencies.get((pk_from, pk_to)) or self.latencies.get((pk_from, None))
158
+ if not spec:
159
+ return 0.0
160
+ lat, jit = spec
161
+ if jit > 0.0:
162
+ d = lat + random.uniform(-jit, jit)
163
+ else:
164
+ d = lat
165
+ return max(0.0, d)
166
+
167
+ def inject_bad_peer(self, node: int | str, offence: str = 'duplicate-flood', **kwargs: Any) -> None:
168
+ """Configure node as an adversarial peer committing protocol violations."""
169
+ with self._lock:
170
+ node_app = self.cluster.get_node(node)
171
+ pk = node_app.ks.node_pk
172
+ self.adversarial_nodes[pk] = {'offence': offence, **kwargs}
173
+
174
+ # If offence is a sync session offence, wrap node's sync session factory
175
+ if offence in badpeer.OFFENCES:
176
+ import functools
177
+ flood_mult = int(kwargs.get('flood', 8))
178
+ node_app.sync.session_cls = functools.partial(
179
+ badpeer.BadPeer,
180
+ offence=offence,
181
+ flood=flood_mult,
182
+ )
183
+
184
+ def is_partitioned(self, pk_from: str, pk_to: str, rung: Optional[str] = None) -> bool:
185
+ with self._lock:
186
+ for item in self.partitions:
187
+ group_a, group_b = item[0], item[1]
188
+ part_rung = item[2] if len(item) > 2 else None
189
+ if (pk_from in group_a and pk_to in group_b) or (pk_from in group_b and pk_to in group_a):
190
+ if part_rung is None or part_rung == rung or (rung is not None and part_rung == 'direct' and rung != 'nostr'):
191
+ return True
192
+ for item in self.directed_partitions:
193
+ src_group, dst_group = item[0], item[1]
194
+ part_rung = item[2] if len(item) > 2 else None
195
+ if pk_from in src_group and pk_to in dst_group:
196
+ if part_rung is None or part_rung == rung or (rung is not None and part_rung == 'direct' and rung != 'nostr'):
197
+ return True
198
+ return False
199
+
200
+ def should_drop(self, pk: str) -> bool:
201
+ with self._lock:
202
+ rate = self.loss_rates.get(pk, 0.0)
203
+ if rate <= 0.0:
204
+ return False
205
+ return random.random() < rate
206
+
207
+ def _ensure_interceptors(self) -> None:
208
+ """Hook into each node's transport router to enforce partition and loss rules."""
209
+ for name, node in self.cluster.nodes.items():
210
+ pk = node.ks.node_pk
211
+ if pk in self._original_sends:
212
+ continue
213
+
214
+ orig_send = getattr(node.transport, '_send_ladder', node.transport.send)
215
+ self._original_sends[pk] = orig_send
216
+
217
+ def make_interceptor(sender_pk: str, original: Callable[..., Any]):
218
+ def intercepted_send(
219
+ message: Mapping[str, Any],
220
+ to_pubkey: str,
221
+ anonymous: bool = False,
222
+ stored: bool = False,
223
+ ) -> dict[str, tuple[bool, str]]:
224
+ if self.is_partitioned(sender_pk, to_pubkey, rung=None):
225
+ return {'chaos_partition': (False, 'network partition in force')}
226
+ if self.should_drop(sender_pk) or self.should_drop(to_pubkey):
227
+ return {'chaos_loss': (False, 'simulated packet loss')}
228
+ delay = self.get_delay(sender_pk, to_pubkey)
229
+ if delay > 0.0:
230
+ time.sleep(delay)
231
+ return original(message, to_pubkey, anonymous=anonymous, stored=stored)
232
+ return intercepted_send
233
+
234
+ def make_rung_interceptor(sender_pk: str):
235
+ def intercepted_rung(
236
+ rung_name: str,
237
+ message: Mapping[str, Any],
238
+ to_pubkey: str,
239
+ target: Any,
240
+ anonymous: bool,
241
+ ) -> Optional[dict[str, tuple[bool, str]]]:
242
+ if self.is_partitioned(sender_pk, to_pubkey, rung=rung_name):
243
+ raise ConnectionResetError(f"chaos: rung '{rung_name}' partitioned")
244
+ return None
245
+ return intercepted_rung
246
+
247
+ interceptor = make_interceptor(pk, orig_send)
248
+ if hasattr(node.transport, 'set_send_interceptor'):
249
+ node.transport.set_send_interceptor(interceptor)
250
+ else:
251
+ # Fallback for custom or mock transports
252
+ setattr(node.transport, 'send', interceptor)
253
+
254
+ if hasattr(node.transport, 'set_rung_interceptor'):
255
+ node.transport.set_rung_interceptor(make_rung_interceptor(pk))
256
+
257
+ def restore(self) -> None:
258
+ """Restore all original transport methods and reset chaos state."""
259
+ with self._lock:
260
+ self.partitions.clear()
261
+ self.directed_partitions.clear()
262
+ self.loss_rates.clear()
263
+ self.latencies.clear()
264
+ self.adversarial_nodes.clear()
265
+ for name, node in self.cluster.nodes.items():
266
+ pk = node.ks.node_pk
267
+ if hasattr(node.transport, 'clear_send_interceptor'):
268
+ node.transport.clear_send_interceptor()
269
+ if hasattr(node.transport, 'clear_rung_interceptor'):
270
+ node.transport.clear_rung_interceptor()
271
+ elif pk in self._original_sends:
272
+ setattr(node.transport, 'send', self._original_sends[pk])
273
+ from fleetmesh_core.storage import sync
274
+ node.sync.session_cls = sync.Session
275
+ self._original_sends.clear()
276
+
277
+ def get_status(self) -> Mapping[str, Any]:
278
+ with self._lock:
279
+ return {
280
+ 'partitions_active': len(self.partitions),
281
+ 'partitions': [
282
+ {'group_a': list(p[0]), 'group_b': list(p[1]), 'rung': p[2] if len(p) > 2 else None}
283
+ for p in self.partitions
284
+ ],
285
+ 'directed_partitions_active': len(self.directed_partitions),
286
+ 'directed_partitions': [
287
+ {'from': list(p[0]), 'to': list(p[1]), 'rung': p[2] if len(p) > 2 else None}
288
+ for p in self.directed_partitions
289
+ ],
290
+ 'packet_loss_nodes': self.loss_rates,
291
+ 'latencies_active': len(self.latencies),
292
+ 'adversarial_nodes': self.adversarial_nodes,
293
+ }
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # LocalCluster Engine
298
+ # ---------------------------------------------------------------------------
299
+
300
+ class LocalCluster:
301
+ """Manages an isolated multi-node MeshNode testnet cluster."""
302
+
303
+ def __init__(
304
+ self,
305
+ base_dir: str,
306
+ node_count: int = 3,
307
+ topology: str = 'mesh',
308
+ shared_relays: bool = True,
309
+ relay_urls: Optional[Sequence[str]] = None,
310
+ clock: Optional[Callable[[], int]] = None,
311
+ pow_bits: int = 8,
312
+ default_shard: str = 'bristol',
313
+ node_classes: Optional[Sequence[str]] = None,
314
+ enable_fips: bool = False,
315
+ fips_sock_path: Optional[str] = None,
316
+ ) -> None:
317
+ self.base_dir: str = os.path.abspath(base_dir)
318
+ self.node_count: int = max(2, node_count)
319
+ self.topology: str = topology
320
+ self.shared_relays: bool = shared_relays
321
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
322
+ self.pow_bits: int = pow_bits
323
+ self.default_shard: str = default_shard
324
+ self.node_classes: list[str] = list(node_classes or ['edge'] * self.node_count)
325
+ while len(self.node_classes) < self.node_count:
326
+ self.node_classes.append('edge')
327
+
328
+ self.enable_fips: bool = enable_fips
329
+ self.fips_sock_path: Optional[str] = fips_sock_path
330
+ self.fips_daemon: Optional[ClusterFipsDaemon] = None
331
+
332
+ self.relay_server: Optional[relayserver.RelayServer] = None
333
+ self.relay_urls: list[str] = list(relay_urls or [])
334
+ self.nodes: dict[str, app_mod.NodeApp] = {}
335
+ self.node_dirs: dict[str, str] = {}
336
+ self.chaos: ChaosSimulator = ChaosSimulator(self)
337
+ self.running: bool = False
338
+ self._lock: threading.RLock = threading.RLock()
339
+
340
+ @property
341
+ def status_path(self) -> str:
342
+ return os.path.join(self.base_dir, 'cluster.json')
343
+
344
+ def resolve_node_pubkey(self, node: int | str) -> str:
345
+ """Resolve node index or name or pubkey to a canonical 64-hex node pubkey."""
346
+ if isinstance(node, int):
347
+ if not 0 <= node < self.node_count:
348
+ raise IndexError(f"node index {node} out of range (0..{self.node_count-1})")
349
+ name = f'node_{node}'
350
+ if name in self.nodes:
351
+ return self.nodes[name].ks.node_pk
352
+ ndir = self.node_dirs.get(name) or os.path.join(self.base_dir, name)
353
+ kpath = os.path.join(ndir, 'keys.json')
354
+ if os.path.exists(kpath):
355
+ k = json.load(open(kpath, encoding='utf-8'))
356
+ return k.get('node_pk', '')
357
+ raise KeyError(f"node index {node} not initialized on disk")
358
+ s = str(node).strip()
359
+ if s in self.nodes:
360
+ return self.nodes[s].ks.node_pk
361
+ if s.startswith('did:nostr:'):
362
+ s = s.split(':')[-1]
363
+ for name, n in self.nodes.items():
364
+ if n.ks.node_pk.startswith(s.lower()):
365
+ return n.ks.node_pk
366
+ # If not running yet, inspect directory configs
367
+ for i in range(self.node_count):
368
+ ndir = os.path.join(self.base_dir, f'node_{i}')
369
+ kpath = os.path.join(ndir, 'keys.json')
370
+ if os.path.exists(kpath):
371
+ try:
372
+ k = json.load(open(kpath, encoding='utf-8'))
373
+ npk = k.get('node_pk', '')
374
+ if npk.startswith(s.lower()):
375
+ return npk
376
+ except Exception:
377
+ pass
378
+ raise KeyError(f"node identifier {node!r} not found in cluster")
379
+
380
+ def get_node(self, node: int | str) -> app_mod.NodeApp:
381
+ pk = self.resolve_node_pubkey(node)
382
+ for name, n in self.nodes.items():
383
+ if n.ks.node_pk == pk:
384
+ return n
385
+ raise KeyError(f"node {node!r} is not currently running")
386
+
387
+ def init(self) -> LocalCluster:
388
+ """Initialize cluster directory and node configurations on disk."""
389
+ os.makedirs(self.base_dir, exist_ok=True)
390
+ # Separate cluster operator key ensuring 3-Layer Operator Independence Model
391
+ operator_ks = keystore.Keystore.generate()
392
+ operator_pk = operator_ks.node_pk
393
+
394
+ if self.enable_fips and self.fips_daemon is None:
395
+ if self.fips_sock_path:
396
+ sock_p = self.fips_sock_path
397
+ else:
398
+ candidate = os.path.join(self.base_dir, 'fips.sock')
399
+ if len(candidate) > 90:
400
+ # sun_path is 108 bytes; a deep base_dir does not fit.
401
+ # The fallback used to be a predictable name in /tmp,
402
+ # which any local user could create ahead of us and
403
+ # then answer as the daemon. A fresh private directory
404
+ # (mode 0700) under $XDG_RUNTIME_DIR, or mkdtemp's
405
+ # default, is ours alone; the name is unguessable.
406
+ run_dir = os.environ.get('XDG_RUNTIME_DIR')
407
+ if run_dir and not os.path.isdir(run_dir):
408
+ run_dir = None
409
+ sock_p = os.path.join(tempfile.mkdtemp(prefix='ms_fips_', dir=run_dir), 'fips.sock')
410
+ else:
411
+ sock_p = candidate
412
+ self.fips_sock_path = sock_p
413
+ self.fips_daemon = ClusterFipsDaemon(sock_p)
414
+
415
+ for i in range(self.node_count):
416
+ name = f'node_{i}'
417
+ ndir = os.path.join(self.base_dir, name)
418
+ self.node_dirs[name] = ndir
419
+ os.makedirs(ndir, exist_ok=True)
420
+
421
+ ks = keystore.Keystore.load_or_generate(ndir)
422
+
423
+ # Node config
424
+ cpath = os.path.join(ndir, 'config.json')
425
+ if not os.path.exists(cpath):
426
+ node_cls = self.node_classes[i]
427
+ eps = []
428
+ if self.enable_fips and self.fips_daemon is not None:
429
+ eps.append({
430
+ 'transport': 'fips-native',
431
+ 'address': f"{ks.node_pk}:11801",
432
+ 'priority': 5,
433
+ 'sock_path': self.fips_daemon.sock_path,
434
+ })
435
+ # `class` is a keyword, so it goes in as a key. (It went in as
436
+ # `class_` once, and every node ran as the default class; the
437
+ # config check now refuses a key no compartment reads.)
438
+ cfg = dict(
439
+ app_mod.defaults(),
440
+ name=name,
441
+ operator=operator_pk,
442
+ pow_bits=self.pow_bits,
443
+ heartbeat=True,
444
+ tick_seconds=2,
445
+ face={'host': '127.0.0.1', 'port': 0},
446
+ relays=self.relay_urls,
447
+ endpoints=eps,
448
+ coverage=[{'shard': self.default_shard, 'filter': {'kinds': [1], '#t': [self.default_shard]},
449
+ 'completeness': 'asserted'}],
450
+ )
451
+ cfg['class'] = node_cls
452
+ app_mod.save_config(ndir, cfg)
453
+
454
+ self._save_cluster_meta()
455
+ return self
456
+
457
+ def start(self, timeout: float = 10.0) -> LocalCluster:
458
+ """Start the cluster: relay server, all node instances, and establish peering mesh."""
459
+ with self._lock:
460
+ if self.running:
461
+ return self
462
+
463
+ self.init()
464
+
465
+ if self.enable_fips and self.fips_daemon is None:
466
+ sock_p = self.fips_sock_path or os.path.join(self.base_dir, 'fips_control.sock')
467
+ self.fips_daemon = ClusterFipsDaemon(sock_p)
468
+
469
+ # 1. Start local relay server if requested and none specified
470
+ if self.shared_relays and not self.relay_urls:
471
+ self.relay_server = relayserver.RelayServer(clock=self.clock).start()
472
+ self.relay_urls = [self.relay_server.url]
473
+
474
+ # 2. Start each node
475
+ for i in range(self.node_count):
476
+ name = f'node_{i}'
477
+ ndir = self.node_dirs[name]
478
+ cfg = dict(app_mod.load_config(ndir))
479
+ cfg['relays'] = list(self.relay_urls)
480
+ if self.enable_fips and self.fips_daemon is not None:
481
+ ks = keystore.Keystore.load(ndir)
482
+ cfg['endpoints'] = [
483
+ {
484
+ 'transport': 'fips-native',
485
+ 'address': f"{ks.node_pk}:11801",
486
+ 'priority': 5,
487
+ 'sock_path': self.fips_daemon.sock_path,
488
+ }
489
+ ]
490
+ app_mod.save_config(ndir, cfg)
491
+
492
+ node = app_mod.NodeApp(ndir, cfg, clock=self.clock)
493
+ node.start()
494
+ self.nodes[name] = node
495
+
496
+ # 3. Wait for all inboxes to listen
497
+ time.sleep(0.05)
498
+
499
+ # 4. If FIPS native is enabled, exchange descriptors directly across cluster
500
+ if self.enable_fips:
501
+ for name_a, node_a in self.nodes.items():
502
+ for name_b, node_b in self.nodes.items():
503
+ if name_a != name_b and node_b.descriptor is not None:
504
+ node_a.peers.learn(node_b.descriptor)
505
+
506
+ # 5. Automate peering proposals based on configured topology
507
+ edges = generate_topology_edges(self.node_count, self.topology)
508
+ filter_spec = {'kinds': [1], '#t': [self.default_shard]}
509
+
510
+ for idx_a, idx_b in edges:
511
+ node_a = self.nodes[f'node_{idx_a}']
512
+ node_b = self.nodes[f'node_{idx_b}']
513
+ try:
514
+ node_a.peering.propose(
515
+ node_b.ks.node_pk,
516
+ [{'shard': self.default_shard, 'filter': filter_spec, 'mode': 'both'}],
517
+ timeout=min(5.0, timeout),
518
+ )
519
+ except Exception:
520
+ pass
521
+
522
+ # 6. Wait for mutual grants to converge
523
+ deadline = time.time() + timeout
524
+ while time.time() < deadline:
525
+ all_peered = True
526
+ for idx_a, idx_b in edges:
527
+ na = self.nodes[f'node_{idx_a}']
528
+ nb = self.nodes[f'node_{idx_b}']
529
+ g_a = na.grants.get(nb.ks.node_pk, grants.ISSUED)
530
+ g_b = nb.grants.get(na.ks.node_pk, grants.ISSUED)
531
+ if not (g_a and g_b):
532
+ all_peered = False
533
+ break
534
+ if all_peered:
535
+ break
536
+ time.sleep(0.02)
537
+
538
+ self.running = True
539
+ self._save_cluster_meta()
540
+ return self
541
+
542
+ def stop_node(self, node: int | str) -> None:
543
+ """Stop an individual node instance in the cluster (simulating node crash / churn)."""
544
+ with self._lock:
545
+ pk = self.resolve_node_pubkey(node)
546
+ for name, n in list(self.nodes.items()):
547
+ if n.ks.node_pk == pk:
548
+ try:
549
+ n.stop()
550
+ except Exception:
551
+ pass
552
+ del self.nodes[name]
553
+ return
554
+ raise KeyError(f"node {node!r} is not running")
555
+
556
+ def start_node(self, node_idx: int) -> app_mod.NodeApp:
557
+ """Start or restart a single node instance from its persisted directory."""
558
+ with self._lock:
559
+ name = f'node_{node_idx}'
560
+ if name in self.nodes:
561
+ return self.nodes[name]
562
+ ndir = self.node_dirs[name]
563
+ cfg = app_mod.load_config(ndir)
564
+ node = app_mod.NodeApp(ndir, cfg, clock=self.clock)
565
+ node.start()
566
+ self.nodes[name] = node
567
+ return node
568
+
569
+ def stop(self) -> None:
570
+ """Stop all cluster nodes and release local relays."""
571
+ with self._lock:
572
+ self.chaos.restore()
573
+ for name, node in self.nodes.items():
574
+ try:
575
+ node.stop()
576
+ except Exception:
577
+ pass
578
+ self.nodes.clear()
579
+
580
+ if self.fips_daemon is not None:
581
+ try:
582
+ self.fips_daemon.stop()
583
+ except Exception:
584
+ pass
585
+ if self.fips_sock_path and os.path.exists(self.fips_sock_path):
586
+ try:
587
+ os.unlink(self.fips_sock_path)
588
+ except OSError:
589
+ pass
590
+ self.fips_daemon = None
591
+
592
+ if self.relay_server is not None:
593
+ try:
594
+ self.relay_server.stop()
595
+ except Exception:
596
+ pass
597
+ self.relay_server = None
598
+
599
+ self.running = False
600
+ self._save_cluster_meta()
601
+
602
+ def tick(self, ticks: int = 1) -> list[list[Any]]:
603
+ """Advance time or execute tick cycles across all cluster nodes."""
604
+ results = []
605
+ for _ in range(ticks):
606
+ cycle = []
607
+ for name, node in self.nodes.items():
608
+ cycle.append((name, node.tick()))
609
+ results.append(cycle)
610
+ return results
611
+
612
+ def sync_pair(self, idx_a: int | str, idx_b: int | str, shard: Optional[str] = None) -> Mapping[str, Any]:
613
+ """Trigger an immediate sync pull from Node A to Node B."""
614
+ try:
615
+ node_a = self.get_node(idx_a)
616
+ node_b = self.get_node(idx_b)
617
+ except KeyError as exc:
618
+ return {'status': 'offline', 'error': str(exc)}
619
+ s = shard or self.default_shard
620
+ filter_spec = {'kinds': [1], '#t': [s]}
621
+ return node_a.sync.pull(node_b.ks.node_pk, s, filter_spec, timeout=10)
622
+
623
+ def sync_all(self, shard: Optional[str] = None) -> list[Mapping[str, Any]]:
624
+ """Trigger a sync cycle across all topology edges."""
625
+ s = shard or self.default_shard
626
+ edges = generate_topology_edges(self.node_count, self.topology)
627
+ results: list[Mapping[str, Any]] = []
628
+ for idx_a, idx_b in edges:
629
+ res_ab = self.sync_pair(idx_a, idx_b, shard=s)
630
+ res_ba = self.sync_pair(idx_b, idx_a, shard=s)
631
+ results.append({'pair': (idx_a, idx_b), 'pull_ab': res_ab, 'pull_ba': res_ba})
632
+ return results
633
+
634
+ def publish_event(self, node: int | str, event: NostrEvent) -> str:
635
+ """Store and publish an event at the specified node."""
636
+ target_node = self.get_node(node)
637
+ target_node.store._put(event)
638
+ if self.relay_urls:
639
+ relay.publish_all(self.relay_urls, event, timeout=5)
640
+ return event.get('id', '')
641
+
642
+ def get_status(self) -> Mapping[str, Any]:
643
+ """Collect cluster topology, health, active peerings, and metrics."""
644
+ node_summaries = []
645
+ edges = generate_topology_edges(self.node_count, self.topology)
646
+ active_edge_count = 0
647
+
648
+ for i in range(self.node_count):
649
+ name = f'node_{i}'
650
+ ndir = self.node_dirs.get(name) or os.path.join(self.base_dir, name)
651
+ info = {
652
+ 'index': i,
653
+ 'name': name,
654
+ 'dir': ndir,
655
+ 'status': 'running' if name in self.nodes else 'offline',
656
+ }
657
+
658
+ if name in self.nodes:
659
+ node = self.nodes[name]
660
+ info['pubkey'] = node.ks.node_pk
661
+ info['did'] = node.ks.did
662
+ info['class'] = node.cfg.get('class', 'edge')
663
+ info['face'] = node.face.url if getattr(node, 'face', None) else None
664
+ info['event_count'] = node.store.count()
665
+ # peer_view() carries issued/received/standing; GrantStore.list()
666
+ # never did, so these read None for every peer until the row was
667
+ # typed.
668
+ peer_list = []
669
+ for p in node.peer_view():
670
+ issued = p['issued']
671
+ received = p['received']
672
+ peer_list.append({
673
+ 'peer': p['peer'],
674
+ 'issued_tier': issued['tier'] if issued else None,
675
+ 'received_tier': received['tier'] if received else None,
676
+ 'standing': p.get('standing'),
677
+ })
678
+ info['peers'] = peer_list
679
+ info['refusals'] = len(node.subsystems.refusals(100))
680
+ else:
681
+ # Inspect offline files
682
+ if os.path.exists(os.path.join(ndir, 'keys.json')):
683
+ ks_data = json.load(open(os.path.join(ndir, 'keys.json'), encoding='utf-8'))
684
+ info['pubkey'] = ks_data.get('node_pk')
685
+ info['did'] = f"did:nostr:{ks_data.get('node_pk')}"
686
+ if os.path.exists(os.path.join(ndir, 'status.json')):
687
+ st = json.load(open(os.path.join(ndir, 'status.json'), encoding='utf-8'))
688
+ info['class'] = st.get('class')
689
+ info['face'] = st.get('face')
690
+
691
+ node_summaries.append(info)
692
+
693
+ # Count mutual active edges
694
+ if self.running:
695
+ for idx_a, idx_b in edges:
696
+ na = self.nodes.get(f'node_{idx_a}')
697
+ nb = self.nodes.get(f'node_{idx_b}')
698
+ if na and nb:
699
+ if na.grants.get(nb.ks.node_pk, grants.ISSUED) and nb.grants.get(na.ks.node_pk, grants.ISSUED):
700
+ active_edge_count += 1
701
+
702
+ status = {
703
+ 'cluster_dir': self.base_dir,
704
+ 'node_count': self.node_count,
705
+ 'topology': self.topology,
706
+ 'running': self.running,
707
+ 'relays': self.relay_urls,
708
+ 'active_peering_edges': f"{active_edge_count}/{len(edges)}",
709
+ 'nodes': node_summaries,
710
+ 'chaos': self.chaos.get_status(),
711
+ }
712
+ if self.enable_fips:
713
+ status['fips'] = {
714
+ 'active': self.fips_daemon is not None and self.fips_daemon.running,
715
+ 'sock_path': self.fips_daemon.sock_path if self.fips_daemon else None,
716
+ 'listeners': len(self.fips_daemon.peer_listeners) if self.fips_daemon else 0,
717
+ }
718
+ return status
719
+
720
+ def _save_cluster_meta(self) -> None:
721
+ data = {
722
+ 'base_dir': self.base_dir,
723
+ 'node_count': self.node_count,
724
+ 'topology': self.topology,
725
+ 'running': self.running,
726
+ 'relays': self.relay_urls,
727
+ 'enable_fips': self.enable_fips,
728
+ 'updated_at': int(time.time()),
729
+ }
730
+ with open(self.status_path, 'w', encoding='utf-8') as f:
731
+ json.dump(data, f, indent=2)