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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/testnet/cluster.py
ADDED
|
@@ -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)
|