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
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
"""Universal Nostr Relay Bridge for fleetmesh.
|
|
2
|
+
|
|
3
|
+
Bridges external standard Nostr relays (strfry, nostr-rs-relay, khatru, coracle, damus)
|
|
4
|
+
with the local fleetmesh node:
|
|
5
|
+
1. Ingress Engine: Connects to designated external relays over WebSocket (via Relay/ws),
|
|
6
|
+
subscribes to shard topics (#t), mesh control kinds (11801, 11803, 30803, etc.) or
|
|
7
|
+
user content kinds (0, 1, 3, 7, 1059, etc.), cryptographically verifies BIP-340 signatures,
|
|
8
|
+
enforces future timestamp and expiry limits, deduplicates, and ingests into the local
|
|
9
|
+
NodeStore with origin recorded.
|
|
10
|
+
2. Egress Engine: Queues and publishes verified events (produced locally or across the mesh)
|
|
11
|
+
out to designated external relays, tracking acceptance, handling NIP-42 auth challenges,
|
|
12
|
+
and retrying on transient errors.
|
|
13
|
+
3. Flow Control & Capacity: Sliding-window rate limiting per relay to protect node
|
|
14
|
+
memory and disk from upstream relay flooding, integrated with the Compartment refusal
|
|
15
|
+
and trouble reporting interfaces.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Callable, cast, Mapping, Optional, Sequence, Union
|
|
23
|
+
from fleetmesh_core.storage import store # noqa: E402
|
|
24
|
+
from node import channels as ch_mod # noqa: E402
|
|
25
|
+
from node import compartment as compartment_mod # noqa: E402
|
|
26
|
+
from node.compartment import Command, Compartment, Need # noqa: E402
|
|
27
|
+
from node.config.parameter import Parameter # noqa: E402
|
|
28
|
+
from node import metrics as mx_mod # noqa: E402
|
|
29
|
+
from node.protocols.nostr import client as relay # noqa: E402
|
|
30
|
+
from fleetmesh_core.crypto import giftwrap # noqa: E402
|
|
31
|
+
from fleetmesh_core.wire.interfaces import NostrEvent # noqa: E402
|
|
32
|
+
from node.config import BridgeConfig # noqa: E402
|
|
33
|
+
from node.config import compartments as cfgblocks # noqa: E402
|
|
34
|
+
from node.compartment import stores as st # noqa: E402
|
|
35
|
+
from node.planes.transport.bridge.ratelimit import RateLimiter # noqa: E402 (shared, audit D4)
|
|
36
|
+
|
|
37
|
+
# The defaults live on the config block (node/config/compartments.py) so the
|
|
38
|
+
# check and the compartment cannot disagree; re-exported here for callers.
|
|
39
|
+
DEFAULT_INGRESS_KINDS = cfgblocks.DEFAULT_INGRESS_KINDS
|
|
40
|
+
DEFAULT_EGRESS_KINDS = cfgblocks.DEFAULT_EGRESS_KINDS
|
|
41
|
+
DEFAULT_INGRESS_FILTERS = cfgblocks.DEFAULT_INGRESS_FILTERS
|
|
42
|
+
DEFAULT_RATE_LIMIT_PER_MIN = cfgblocks.DEFAULT_RATE_LIMIT_PER_MIN
|
|
43
|
+
DEFAULT_MAX_FUTURE_SECONDS = cfgblocks.DEFAULT_MAX_FUTURE_SECONDS
|
|
44
|
+
DEFAULT_MAX_EVENT_BYTES = cfgblocks.DEFAULT_MAX_EVENT_BYTES
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
from .types import (
|
|
48
|
+
RelayWorkerStats,
|
|
49
|
+
BridgeStateReport,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class BridgeError(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RelayBridgeWorker:
|
|
57
|
+
"""Manages an active subscription and streaming connection to a single external Nostr relay."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
url: str,
|
|
62
|
+
filters: Sequence[Mapping[str, Any]],
|
|
63
|
+
on_event: Callable[[NostrEvent, str], Any],
|
|
64
|
+
auth_signer: Optional[Callable[[str, str], Optional[Mapping[str, Any]]]] = None,
|
|
65
|
+
timeout: float = 15.0,
|
|
66
|
+
stop_event: Optional[threading.Event] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.url: str = url
|
|
69
|
+
self.filters: list[Mapping[str, Any]] = list(filters)
|
|
70
|
+
self.on_event: Callable[[NostrEvent, str], Any] = on_event
|
|
71
|
+
self.auth_signer: Optional[Callable[[str, str], Optional[Mapping[str, Any]]]] = auth_signer
|
|
72
|
+
self.timeout: float = timeout
|
|
73
|
+
|
|
74
|
+
self.connected: bool = False
|
|
75
|
+
self.last_connected_at: Optional[int] = None
|
|
76
|
+
self.ingress_count: int = 0
|
|
77
|
+
self.egress_count: int = 0
|
|
78
|
+
self.errors_count: int = 0
|
|
79
|
+
self.last_error: Optional[str] = None
|
|
80
|
+
# Exceptions raised by on_event; the subscription outlives them.
|
|
81
|
+
self.handler_errors: int = 0
|
|
82
|
+
self.last_handler_error: Optional[str] = None
|
|
83
|
+
self.last_event_at: Optional[int] = None
|
|
84
|
+
|
|
85
|
+
self._stop_event: threading.Event = threading.Event()
|
|
86
|
+
self._bridge_stop_event: Optional[threading.Event] = stop_event
|
|
87
|
+
self._thread: Optional[threading.Thread] = None
|
|
88
|
+
self._relay: Optional[relay.Relay] = None
|
|
89
|
+
self._lock: threading.Lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
def _is_stopped(self) -> bool:
|
|
92
|
+
return self._stop_event.is_set() or (self._bridge_stop_event is not None and self._bridge_stop_event.is_set())
|
|
93
|
+
|
|
94
|
+
def start(self) -> None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._thread is not None and self._thread.is_alive():
|
|
97
|
+
return
|
|
98
|
+
self._stop_event.clear()
|
|
99
|
+
self._thread = threading.Thread(target=self._run, name=f'relay-bridge-{self.url}', daemon=True)
|
|
100
|
+
self._thread.start()
|
|
101
|
+
|
|
102
|
+
def stop(self) -> None:
|
|
103
|
+
self._stop_event.set()
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._relay is not None:
|
|
106
|
+
try:
|
|
107
|
+
self._relay.close()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
self._relay = None
|
|
111
|
+
self.connected = False
|
|
112
|
+
if self._thread is not None and self._thread.is_alive():
|
|
113
|
+
self._thread.join(timeout=1.0)
|
|
114
|
+
self._thread = None
|
|
115
|
+
|
|
116
|
+
def _run(self) -> None:
|
|
117
|
+
consecutive_errors = 0
|
|
118
|
+
while not self._is_stopped():
|
|
119
|
+
try:
|
|
120
|
+
r = relay.Relay(self.url, timeout=self.timeout, auth_signer=self.auth_signer)
|
|
121
|
+
r.connect()
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._relay = r
|
|
124
|
+
self.connected = True
|
|
125
|
+
self.last_connected_at = int(time.time())
|
|
126
|
+
self.last_error = None
|
|
127
|
+
consecutive_errors = 0
|
|
128
|
+
|
|
129
|
+
sid = r.subscribe(self.filters, ready=False)
|
|
130
|
+
for ev in r.events(sid):
|
|
131
|
+
if self._is_stopped():
|
|
132
|
+
break
|
|
133
|
+
self.ingress_count += 1
|
|
134
|
+
self.last_event_at = int(time.time())
|
|
135
|
+
try:
|
|
136
|
+
self.on_event(ev, self.url)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
# A handler bug (or one poisoned event) costs that
|
|
139
|
+
# event, not the subscription: tearing the socket
|
|
140
|
+
# down and reconnecting would replay the same
|
|
141
|
+
# event and fail the same way, forever.
|
|
142
|
+
self.handler_errors += 1
|
|
143
|
+
self.last_handler_error = '%s: %s' % (type(e).__name__, str(e)[:200])
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
consecutive_errors += 1
|
|
147
|
+
self.errors_count += 1
|
|
148
|
+
self.last_error = f'{type(e).__name__}: {e}'
|
|
149
|
+
with self._lock:
|
|
150
|
+
self.connected = False
|
|
151
|
+
if self._relay is not None:
|
|
152
|
+
try:
|
|
153
|
+
self._relay.close()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
self._relay = None
|
|
157
|
+
finally:
|
|
158
|
+
with self._lock:
|
|
159
|
+
self.connected = False
|
|
160
|
+
if self._relay is not None:
|
|
161
|
+
try:
|
|
162
|
+
self._relay.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
self._relay = None
|
|
166
|
+
|
|
167
|
+
if not self._is_stopped():
|
|
168
|
+
backoff = min(30.0, 1.0 * (2 ** min(consecutive_errors, 5)))
|
|
169
|
+
self._stop_event.wait(backoff)
|
|
170
|
+
|
|
171
|
+
def publish(self, ev: NostrEvent) -> tuple[bool, str]:
|
|
172
|
+
"""Publish one event directly to this relay, returning (accepted, reason)."""
|
|
173
|
+
pub_client = relay.Relay(self.url, timeout=self.timeout, auth_signer=self.auth_signer)
|
|
174
|
+
try:
|
|
175
|
+
pub_client.connect()
|
|
176
|
+
accepted, reason = pub_client.publish(ev)
|
|
177
|
+
if accepted:
|
|
178
|
+
self.egress_count += 1
|
|
179
|
+
return accepted, reason
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self.errors_count += 1
|
|
182
|
+
self.last_error = f'publish: {type(e).__name__}: {e}'
|
|
183
|
+
return False, str(e)
|
|
184
|
+
finally:
|
|
185
|
+
pub_client.close()
|
|
186
|
+
|
|
187
|
+
def stats(self) -> RelayWorkerStats:
|
|
188
|
+
return {
|
|
189
|
+
'url': self.url,
|
|
190
|
+
'connected': self.connected,
|
|
191
|
+
'last_connected_at': self.last_connected_at,
|
|
192
|
+
'ingress_count': self.ingress_count,
|
|
193
|
+
'egress_count': self.egress_count,
|
|
194
|
+
'errors_count': self.errors_count,
|
|
195
|
+
'last_error': self.last_error,
|
|
196
|
+
'handler_errors': self.handler_errors,
|
|
197
|
+
'last_handler_error': self.last_handler_error,
|
|
198
|
+
'last_event_at': self.last_event_at,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class RelayBridge(Compartment):
|
|
203
|
+
optional: bool = True
|
|
204
|
+
enabled_by: Optional[str] = 'bridge'
|
|
205
|
+
"""The Universal Nostr Relay Bridge compartment."""
|
|
206
|
+
|
|
207
|
+
name: str = 'bridge'
|
|
208
|
+
uses: tuple[str, ...] = ('p2p',) # external ingress is re-broadcast on the P2P gossip when it is on
|
|
209
|
+
reads: tuple[str, ...] = ('bridge', 'relays')
|
|
210
|
+
config_cls = BridgeConfig
|
|
211
|
+
live: tuple[str, ...] = ('bridge',)
|
|
212
|
+
NEEDS = (
|
|
213
|
+
Need('store_backend', 'stores.events'),
|
|
214
|
+
Need('face', 'face'),
|
|
215
|
+
Need('relays', 'relays'),
|
|
216
|
+
Need('cfg', block=BridgeConfig),
|
|
217
|
+
Need('p2p', 'p2p'),
|
|
218
|
+
)
|
|
219
|
+
STORES = (
|
|
220
|
+
st.Store(
|
|
221
|
+
key='egress_queue',
|
|
222
|
+
description='events queued for broadcast to external relays',
|
|
223
|
+
kind=st.RING,
|
|
224
|
+
bound=1000,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
egress_queue: st.Ring[tuple[Any, ...]]
|
|
228
|
+
|
|
229
|
+
CHANNELS = (
|
|
230
|
+
ch_mod.Channel('ingress_stored', 'An event received from an external relay was verified and stored',
|
|
231
|
+
level=ch_mod.NOTE, say='bridge stored event {id} from {relay}',
|
|
232
|
+
tags=('bridge', 'ingress')),
|
|
233
|
+
ch_mod.Channel('ingress_refused', 'An event received from an external relay was refused',
|
|
234
|
+
level=ch_mod.TROUBLE, say='bridge refused event from {relay}: {reason}',
|
|
235
|
+
tags=('bridge', 'ingress', 'refused')),
|
|
236
|
+
ch_mod.Channel('egress_sent', 'An event was published to external relays',
|
|
237
|
+
level=ch_mod.NOTE, say='bridge published event {id} to {relay}',
|
|
238
|
+
tags=('bridge', 'egress')),
|
|
239
|
+
ch_mod.Channel('egress_failed', 'Failed to publish event to an external relay',
|
|
240
|
+
level=ch_mod.TROUBLE, say='bridge failed to publish to {relay}: {reason}',
|
|
241
|
+
tags=('bridge', 'egress', 'failed')),
|
|
242
|
+
)
|
|
243
|
+
METRICS = (
|
|
244
|
+
mx_mod.Metric(name='bridge_ingress_stored', description='Events received from external relays and stored.',
|
|
245
|
+
kind=mx_mod.COUNTER, counts='ingress_stored', tags=('bridge', 'ingress')),
|
|
246
|
+
mx_mod.Metric(name='bridge_ingress_refused', description='Events received from external relays and refused.',
|
|
247
|
+
kind=mx_mod.COUNTER, counts='ingress_refused', tags=('bridge', 'ingress')),
|
|
248
|
+
mx_mod.Metric(name='bridge_egress_sent', description='Events published to external relays.',
|
|
249
|
+
kind=mx_mod.COUNTER, counts='egress_sent', tags=('bridge', 'egress')),
|
|
250
|
+
mx_mod.Metric(name='bridge_egress_failed', description='Events that failed publication to external relays.',
|
|
251
|
+
kind=mx_mod.COUNTER, counts='egress_failed', tags=('bridge', 'egress')),
|
|
252
|
+
)
|
|
253
|
+
COMMANDS = (
|
|
254
|
+
Command(
|
|
255
|
+
key='show',
|
|
256
|
+
name='Bridge Status',
|
|
257
|
+
description='display bridge status and connected relays',
|
|
258
|
+
tags=('bridge', 'status'),
|
|
259
|
+
permission='bridge/show',
|
|
260
|
+
run='state',
|
|
261
|
+
reply='dict',
|
|
262
|
+
),
|
|
263
|
+
Command(
|
|
264
|
+
key='publish',
|
|
265
|
+
name='Publish Event',
|
|
266
|
+
description='publish a nostr event to external relays',
|
|
267
|
+
tags=('bridge', 'publish'),
|
|
268
|
+
parameters=(
|
|
269
|
+
Parameter(key='event', name='event', description='nostr event object', type=dict, default=dict, required=True),
|
|
270
|
+
Parameter(key='relays', name='relays', description='target relays list', type=list, default=None, required=False),
|
|
271
|
+
),
|
|
272
|
+
permission='bridge/publish',
|
|
273
|
+
run='publish_cmd',
|
|
274
|
+
reply='dict',
|
|
275
|
+
),
|
|
276
|
+
Command(
|
|
277
|
+
key='sync',
|
|
278
|
+
name='Sync Query',
|
|
279
|
+
description='query external relays for matching events',
|
|
280
|
+
tags=('bridge', 'sync'),
|
|
281
|
+
parameters=(
|
|
282
|
+
Parameter(key='filter', name='filter', description='subscription filter', type=object, default=None, required=False),
|
|
283
|
+
Parameter(key='relay', name='relay', description='target relay URL', type=str, default=None, required=False),
|
|
284
|
+
),
|
|
285
|
+
permission='bridge/sync',
|
|
286
|
+
run='sync_cmd',
|
|
287
|
+
reply='dict',
|
|
288
|
+
),
|
|
289
|
+
Command(
|
|
290
|
+
key='reconcile',
|
|
291
|
+
name='Reconcile With Relay',
|
|
292
|
+
description='reconcile events with a target relay',
|
|
293
|
+
tags=('bridge', 'reconcile'),
|
|
294
|
+
parameters=(
|
|
295
|
+
Parameter(key='relay', name='relay', description='target relay URL', type=str, default=None, required=False),
|
|
296
|
+
Parameter(key='filter', name='filter', description='subscription filter', type=object, default=None, required=False),
|
|
297
|
+
Parameter(key='since', name='since', description='start timestamp', type=int, default=None, required=False),
|
|
298
|
+
Parameter(key='until', name='until', description='end timestamp', type=int, default=None, required=False),
|
|
299
|
+
Parameter(key='limit', name='limit', description='max events', type=int, default=500, required=False),
|
|
300
|
+
),
|
|
301
|
+
permission='bridge/reconcile',
|
|
302
|
+
run='reconcile_cmd',
|
|
303
|
+
reply='dict',
|
|
304
|
+
),
|
|
305
|
+
Command(
|
|
306
|
+
key='add_relay',
|
|
307
|
+
name='Add Relay',
|
|
308
|
+
description='add an external relay URL to bridge',
|
|
309
|
+
tags=('bridge', 'relay'),
|
|
310
|
+
parameters=(
|
|
311
|
+
Parameter(key='url', name='url', description='relay ws:// or wss:// URL', type=str, default='', required=True),
|
|
312
|
+
),
|
|
313
|
+
permission='bridge/add_relay',
|
|
314
|
+
run='add_relay_cmd',
|
|
315
|
+
reply='dict',
|
|
316
|
+
),
|
|
317
|
+
Command(
|
|
318
|
+
key='remove_relay',
|
|
319
|
+
name='Remove Relay',
|
|
320
|
+
description='remove an external relay URL from bridge',
|
|
321
|
+
tags=('bridge', 'relay'),
|
|
322
|
+
parameters=(
|
|
323
|
+
Parameter(key='url', name='url', description='relay URL to remove', type=str, default='', required=True),
|
|
324
|
+
),
|
|
325
|
+
permission='bridge/remove_relay',
|
|
326
|
+
run='remove_relay_cmd',
|
|
327
|
+
reply='dict',
|
|
328
|
+
),
|
|
329
|
+
Command(
|
|
330
|
+
key='cursor',
|
|
331
|
+
name='Bridge Cursors',
|
|
332
|
+
description='query persistent relay checkpoint cursors',
|
|
333
|
+
tags=('bridge', 'cursor'),
|
|
334
|
+
permission='bridge/cursor',
|
|
335
|
+
run='cursor_cmd',
|
|
336
|
+
reply='list',
|
|
337
|
+
),
|
|
338
|
+
Command(
|
|
339
|
+
key='cursors',
|
|
340
|
+
name='Bridge Cursors Alias',
|
|
341
|
+
description='query persistent relay checkpoint cursors (alias)',
|
|
342
|
+
tags=('bridge', 'cursor'),
|
|
343
|
+
permission='bridge/cursor',
|
|
344
|
+
run='cursor_cmd',
|
|
345
|
+
reply='list',
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
TROUBLE_WINDOW: int = 1800
|
|
350
|
+
|
|
351
|
+
def __init__(
|
|
352
|
+
self,
|
|
353
|
+
store_backend: Any,
|
|
354
|
+
face: Optional[Any] = None,
|
|
355
|
+
relays: Optional[Sequence[str]] = None,
|
|
356
|
+
cfg: Optional[Union[BridgeConfig, Mapping[str, Any]]] = None,
|
|
357
|
+
clock: Optional[Callable[[], int]] = None,
|
|
358
|
+
node: Optional[Any] = None,
|
|
359
|
+
p2p: Optional[Any] = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
super().__init__(node, clock)
|
|
362
|
+
self.store: Any = store_backend
|
|
363
|
+
self.face: Optional[Any] = face
|
|
364
|
+
# The P2P compartment when the node built it (declared in `uses`,
|
|
365
|
+
# resolved once by from_context); None on a node without p2p.
|
|
366
|
+
self.p2p: Optional[Any] = p2p
|
|
367
|
+
|
|
368
|
+
# Tests hand a dict; the node hands the typed block. The block is the shape.
|
|
369
|
+
c = cfg if isinstance(cfg, BridgeConfig) else BridgeConfig.from_dict(cfg or {})
|
|
370
|
+
self.enabled: bool = c.enabled
|
|
371
|
+
self.relays: list[str] = [str(u) for u in c.relays] if c.relays is not None else list(relays or []) # not a store: the configured relays
|
|
372
|
+
|
|
373
|
+
self.ingress_enabled: bool = c.ingress.enabled
|
|
374
|
+
self.ingress_filters: list[Mapping[str, Any]] = list(c.ingress.filters) # not a store: ingress filters from config
|
|
375
|
+
self.rate_limit_per_min: int = c.ingress.rate_limit
|
|
376
|
+
self.future_max_seconds: int = c.ingress.max_future_seconds
|
|
377
|
+
self.max_event_bytes: int = c.ingress.max_bytes
|
|
378
|
+
|
|
379
|
+
self.egress_enabled: bool = c.egress.enabled
|
|
380
|
+
self.egress_kinds: set[int] = set(c.egress.kinds) # not a store: egress kinds from config
|
|
381
|
+
self.auto_egress: bool = c.egress.auto
|
|
382
|
+
|
|
383
|
+
self.workers: dict[str, RelayBridgeWorker] = {} # not a store: active worker threads per relay
|
|
384
|
+
self.rate_limiters: dict[str, RateLimiter] = {} # not a store: rate limiters per relay
|
|
385
|
+
self._global_rate_limiter: RateLimiter = RateLimiter(self.rate_limit_per_min * max(1, len(self.relays)))
|
|
386
|
+
self._egress_queue = self.egress_queue
|
|
387
|
+
self._lock: threading.Lock = threading.Lock()
|
|
388
|
+
|
|
389
|
+
# Operational metrics
|
|
390
|
+
self.ingress_stored: int = 0
|
|
391
|
+
self.ingress_duplicates: int = 0
|
|
392
|
+
self.p2p_forward_failures: int = 0
|
|
393
|
+
self.ingress_refused: int = 0
|
|
394
|
+
self.egress_sent: int = 0
|
|
395
|
+
self.egress_failed: int = 0
|
|
396
|
+
self.last_ingress_at: Optional[int] = None
|
|
397
|
+
self.last_egress_at: Optional[int] = None
|
|
398
|
+
|
|
399
|
+
# Wire auto-egress from local face if available
|
|
400
|
+
if self.face is not None and hasattr(self.face, 'on_event_handlers'):
|
|
401
|
+
self.face.on_event_handlers.append(self._on_local_face_event)
|
|
402
|
+
|
|
403
|
+
# Initialize workers
|
|
404
|
+
for r_url in self.relays:
|
|
405
|
+
self._add_worker(r_url)
|
|
406
|
+
# The strfry relay bridge is a plugin now (fleetmesh-strfry,
|
|
407
|
+
# `fleetmesh_strfry.plugin:StrfryBridgePlugin`), not a part of this one.
|
|
408
|
+
|
|
409
|
+
def _add_worker(self, url: str) -> RelayBridgeWorker:
|
|
410
|
+
if url in self.workers:
|
|
411
|
+
return self.workers[url]
|
|
412
|
+
# No NIP-42 signer: the bridge speaks to the external relays
|
|
413
|
+
# unauthenticated. The node has no `auth_signer`, so the previous
|
|
414
|
+
# `getattr(self.node, 'auth_signer')` was always None; a bridge that
|
|
415
|
+
# authenticates is a feature, declared here when it exists.
|
|
416
|
+
worker = RelayBridgeWorker(
|
|
417
|
+
url=url,
|
|
418
|
+
filters=self.ingress_filters,
|
|
419
|
+
on_event=self.handle_ingress_event,
|
|
420
|
+
stop_event=self._stop_event,
|
|
421
|
+
)
|
|
422
|
+
self.workers[url] = worker
|
|
423
|
+
self.rate_limiters[url] = RateLimiter(self.rate_limit_per_min)
|
|
424
|
+
if self.enabled and self.ingress_enabled:
|
|
425
|
+
worker.start()
|
|
426
|
+
return worker
|
|
427
|
+
|
|
428
|
+
def _remove_worker(self, url: str) -> None:
|
|
429
|
+
worker = self.workers.pop(url, None)
|
|
430
|
+
self.rate_limiters.pop(url, None)
|
|
431
|
+
if worker is not None:
|
|
432
|
+
worker.stop()
|
|
433
|
+
|
|
434
|
+
def _on_local_face_event(self, ev: NostrEvent) -> None:
|
|
435
|
+
"""Triggered whenever the local node's relay face accepts an event."""
|
|
436
|
+
if not self.enabled or not self.egress_enabled or not self.auto_egress:
|
|
437
|
+
return
|
|
438
|
+
if ev.get('kind') in self.egress_kinds:
|
|
439
|
+
self.enqueue_egress(ev)
|
|
440
|
+
|
|
441
|
+
def handle_ingress_event(self, ev: Any, relay_url: str) -> str:
|
|
442
|
+
"""Ingest, validate, and store an event received from an external relay.
|
|
443
|
+
|
|
444
|
+
Returns one of: stored, duplicate, rate_limited, invalid, future_skew, expired.
|
|
445
|
+
"""
|
|
446
|
+
now = self.clock()
|
|
447
|
+
eid = ev.get('id', '') if isinstance(ev, dict) else ''
|
|
448
|
+
|
|
449
|
+
# Rate limiting: relay and global
|
|
450
|
+
relay_limiter = self.rate_limiters.setdefault(relay_url, RateLimiter(self.rate_limit_per_min))
|
|
451
|
+
if not relay_limiter.allow(now) or not self._global_rate_limiter.allow(now):
|
|
452
|
+
self.ingress_refused += 1
|
|
453
|
+
self.say('ingress_refused', relay=relay_url, reason='rate limit exceeded')
|
|
454
|
+
self.refusals.refuse('ingress', 'rate limit exceeded', subject=eid[:16] or None,
|
|
455
|
+
detail={'relay': relay_url, 'rate_limit': self.rate_limit_per_min})
|
|
456
|
+
return 'rate_limited'
|
|
457
|
+
|
|
458
|
+
if not isinstance(ev, dict) or not isinstance(eid, str) or not eid:
|
|
459
|
+
self.ingress_refused += 1
|
|
460
|
+
self.say('ingress_refused', relay=relay_url, reason='malformed event')
|
|
461
|
+
self.refusals.refuse('ingress', 'malformed event object', subject=None,
|
|
462
|
+
detail={'relay': relay_url})
|
|
463
|
+
return 'invalid'
|
|
464
|
+
|
|
465
|
+
# Check payload size
|
|
466
|
+
raw_size = len(json.dumps(ev, separators=(',', ':')))
|
|
467
|
+
if raw_size > self.max_event_bytes:
|
|
468
|
+
self.ingress_refused += 1
|
|
469
|
+
self.say('ingress_refused', relay=relay_url, reason='event exceeds max size')
|
|
470
|
+
self.refusals.refuse('ingress', 'event exceeds max size', subject=eid[:16],
|
|
471
|
+
detail={'relay': relay_url, 'size': raw_size, 'limit': self.max_event_bytes})
|
|
472
|
+
return 'invalid'
|
|
473
|
+
|
|
474
|
+
# Check cryptographic signature & ID
|
|
475
|
+
if not giftwrap.verify_event(ev):
|
|
476
|
+
self.ingress_refused += 1
|
|
477
|
+
self.say('ingress_refused', relay=relay_url, reason='signature verification failed')
|
|
478
|
+
self.refusals.refuse('ingress', 'signature or id does not verify', subject=eid[:16],
|
|
479
|
+
detail={'relay': relay_url, 'pubkey': ev.get('pubkey')})
|
|
480
|
+
return 'invalid'
|
|
481
|
+
|
|
482
|
+
# Timestamp sanity check (clock skew)
|
|
483
|
+
created_at = ev.get('created_at')
|
|
484
|
+
if isinstance(created_at, int) and created_at > now + self.future_max_seconds:
|
|
485
|
+
self.ingress_refused += 1
|
|
486
|
+
self.say('ingress_refused', relay=relay_url, reason='future timestamp')
|
|
487
|
+
self.refusals.refuse('ingress', 'created_at too far in future', subject=eid[:16],
|
|
488
|
+
detail={'relay': relay_url, 'skew': created_at - now, 'limit': self.future_max_seconds})
|
|
489
|
+
return 'future_skew'
|
|
490
|
+
|
|
491
|
+
# Expiration check (NIP-40)
|
|
492
|
+
exp = store.expiration(ev)
|
|
493
|
+
if exp is not None and exp <= now:
|
|
494
|
+
return 'expired'
|
|
495
|
+
|
|
496
|
+
# Ephemeral check
|
|
497
|
+
if store.is_ephemeral(ev.get('kind', 0)):
|
|
498
|
+
if self.face is not None and hasattr(self.face, '_fanout'):
|
|
499
|
+
self.face._fanout(ev)
|
|
500
|
+
return 'ephemeral'
|
|
501
|
+
|
|
502
|
+
# Deduplication check
|
|
503
|
+
if hasattr(self.store, 'has_event') and self.store.has_event(eid):
|
|
504
|
+
self.ingress_duplicates += 1
|
|
505
|
+
return 'duplicate'
|
|
506
|
+
|
|
507
|
+
# Insert into store
|
|
508
|
+
result = self.store.insert_event(ev, now)
|
|
509
|
+
if result == 'stored':
|
|
510
|
+
self.ingress_stored += 1
|
|
511
|
+
self.last_ingress_at = now
|
|
512
|
+
self.say('ingress_stored', id=eid[:16], relay=relay_url)
|
|
513
|
+
if hasattr(self.store, 'note_origin'):
|
|
514
|
+
self.store.note_origin(eid, getattr(self.store, 'LOCAL', 'local'))
|
|
515
|
+
if self.face is not None and hasattr(self.face, '_fanout'):
|
|
516
|
+
self.face._fanout(ev)
|
|
517
|
+
# P2P cross-forwarding: external ingress goes out on the local
|
|
518
|
+
# gossip too. A failure there is the p2p compartment's to report;
|
|
519
|
+
# here it is counted so the bridge's own status says it happened.
|
|
520
|
+
if self.p2p is not None:
|
|
521
|
+
try:
|
|
522
|
+
self.p2p.broadcast(ev)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
self.p2p_forward_failures += 1
|
|
525
|
+
self.refusals.refuse('p2p forward', 'raised', subject=ev.get('id'), detail=compartment_mod.why_detail(e))
|
|
526
|
+
# Persistent checkpoint tracking
|
|
527
|
+
if hasattr(self.store, 'set_bridge_cursor') and relay_url:
|
|
528
|
+
try:
|
|
529
|
+
self.store.set_bridge_cursor(relay_url, last_event_at=ev.get('created_at', now), count_increment=1)
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
532
|
+
elif result == 'duplicate':
|
|
533
|
+
self.ingress_duplicates += 1
|
|
534
|
+
|
|
535
|
+
return result
|
|
536
|
+
|
|
537
|
+
def publish(
|
|
538
|
+
self,
|
|
539
|
+
ev: NostrEvent,
|
|
540
|
+
target_relays: Optional[Sequence[str]] = None,
|
|
541
|
+
) -> dict[str, tuple[bool, str]]:
|
|
542
|
+
"""Publish an event to external relays.
|
|
543
|
+
|
|
544
|
+
Returns {relay_url: (accepted, reason)}.
|
|
545
|
+
"""
|
|
546
|
+
targets = list(target_relays) if target_relays else list(self.workers.keys())
|
|
547
|
+
out: dict[str, tuple[bool, str]] = {}
|
|
548
|
+
for r_url in targets:
|
|
549
|
+
worker = self.workers.get(r_url)
|
|
550
|
+
if worker is None:
|
|
551
|
+
worker = self._add_worker(r_url)
|
|
552
|
+
accepted, reason = worker.publish(ev)
|
|
553
|
+
out[r_url] = (accepted, reason)
|
|
554
|
+
if accepted:
|
|
555
|
+
self.egress_sent += 1
|
|
556
|
+
self.say('egress_sent', id=ev.get('id', '')[:16], relay=r_url)
|
|
557
|
+
else:
|
|
558
|
+
self.egress_failed += 1
|
|
559
|
+
self.say('egress_failed', relay=r_url, reason=reason[:32])
|
|
560
|
+
self.refusals.refuse('egress', f'relay rejected: {reason[:32]}',
|
|
561
|
+
subject=ev.get('id', '')[:16],
|
|
562
|
+
detail={'relay': r_url, 'reason': reason})
|
|
563
|
+
if out:
|
|
564
|
+
self.last_egress_at = self.clock()
|
|
565
|
+
return out
|
|
566
|
+
|
|
567
|
+
def enqueue_egress(self, ev: NostrEvent, target_relays: Optional[Sequence[str]] = None) -> None:
|
|
568
|
+
"""Enqueue an event to be published out to external relays asynchronously."""
|
|
569
|
+
with self._lock:
|
|
570
|
+
targets = list(target_relays) if target_relays else None
|
|
571
|
+
self.egress_queue.append((ev, targets))
|
|
572
|
+
|
|
573
|
+
def flush_egress(self) -> int:
|
|
574
|
+
"""Process and send all queued egress events. Returns count of processed items."""
|
|
575
|
+
items: list[tuple[NostrEvent, Optional[list[str]]]] = []
|
|
576
|
+
with self._lock:
|
|
577
|
+
items = list(self.egress_queue)
|
|
578
|
+
self.egress_queue.clear()
|
|
579
|
+
for ev, targets in items:
|
|
580
|
+
self.publish(ev, targets)
|
|
581
|
+
return len(items)
|
|
582
|
+
|
|
583
|
+
def sync_query(
|
|
584
|
+
self,
|
|
585
|
+
filters: Sequence[Mapping[str, Any]],
|
|
586
|
+
target_relays: Optional[Sequence[str]] = None,
|
|
587
|
+
timeout: float = 15.0,
|
|
588
|
+
) -> Mapping[str, Any]:
|
|
589
|
+
"""Perform an ad-hoc query against external relays and ingest matching events."""
|
|
590
|
+
targets = list(target_relays) if target_relays else list(self.workers.keys())
|
|
591
|
+
results = relay.query_all(targets, filters, timeout=timeout)
|
|
592
|
+
ingested = 0
|
|
593
|
+
duplicates = 0
|
|
594
|
+
for ev in results:
|
|
595
|
+
outcome = self.handle_ingress_event(ev, 'query_sync')
|
|
596
|
+
if outcome == 'stored':
|
|
597
|
+
ingested += 1
|
|
598
|
+
elif outcome == 'duplicate':
|
|
599
|
+
duplicates += 1
|
|
600
|
+
return {
|
|
601
|
+
'queried_relays': targets,
|
|
602
|
+
'events_found': len(results),
|
|
603
|
+
'ingested': ingested,
|
|
604
|
+
'duplicates': duplicates,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
def reconcile_with_relay(
|
|
608
|
+
self,
|
|
609
|
+
relay_url: str,
|
|
610
|
+
filter_spec: Optional[Mapping[str, Any]] = None,
|
|
611
|
+
since: Optional[int] = None,
|
|
612
|
+
until: Optional[int] = None,
|
|
613
|
+
limit: int = 500,
|
|
614
|
+
) -> Mapping[str, Any]:
|
|
615
|
+
"""Perform a range-bounded reconciliation against an external relay to backfill historical events."""
|
|
616
|
+
base_filter = dict(filter_spec or {})
|
|
617
|
+
if 'kinds' not in base_filter:
|
|
618
|
+
base_filter['kinds'] = list(DEFAULT_INGRESS_KINDS)
|
|
619
|
+
if since is None and hasattr(self.store, 'get_bridge_cursor'):
|
|
620
|
+
try:
|
|
621
|
+
cur = self.store.get_bridge_cursor(relay_url)
|
|
622
|
+
if cur and cur.get('last_reconciled_at'):
|
|
623
|
+
since = cur['last_reconciled_at']
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
if since is not None:
|
|
627
|
+
base_filter['since'] = since
|
|
628
|
+
if until is not None:
|
|
629
|
+
base_filter['until'] = until
|
|
630
|
+
base_filter['limit'] = limit
|
|
631
|
+
|
|
632
|
+
results = relay.query_all([relay_url], [base_filter], timeout=15.0)
|
|
633
|
+
|
|
634
|
+
examined = len(results)
|
|
635
|
+
stored_count = 0
|
|
636
|
+
duplicate_count = 0
|
|
637
|
+
for ev in results:
|
|
638
|
+
outcome = self.handle_ingress_event(ev, relay_url)
|
|
639
|
+
if outcome == 'stored':
|
|
640
|
+
stored_count += 1
|
|
641
|
+
elif outcome == 'duplicate':
|
|
642
|
+
duplicate_count += 1
|
|
643
|
+
|
|
644
|
+
target_until = until if until is not None else self.clock()
|
|
645
|
+
if hasattr(self.store, 'set_bridge_cursor'):
|
|
646
|
+
try:
|
|
647
|
+
self.store.set_bridge_cursor(relay_url, last_reconciled_at=target_until, count_increment=0)
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
'relay': relay_url,
|
|
653
|
+
'filter': base_filter,
|
|
654
|
+
'examined': examined,
|
|
655
|
+
'stored': stored_count,
|
|
656
|
+
'duplicates': duplicate_count,
|
|
657
|
+
'cursor': target_until,
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# ---- Compartment lifecycle & control -------------------------------------------
|
|
661
|
+
def start(self) -> None:
|
|
662
|
+
super().start()
|
|
663
|
+
if not self.enabled:
|
|
664
|
+
return
|
|
665
|
+
for worker in self.workers.values():
|
|
666
|
+
if self.ingress_enabled:
|
|
667
|
+
worker.start()
|
|
668
|
+
|
|
669
|
+
def stop(self) -> list[tuple[str, str]]:
|
|
670
|
+
super().stop()
|
|
671
|
+
for worker in list(self.workers.values()):
|
|
672
|
+
worker.stop()
|
|
673
|
+
self.flush_egress()
|
|
674
|
+
return [('stopped bridge', f'{len(self.workers)} workers stopped')]
|
|
675
|
+
|
|
676
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
677
|
+
did: list[tuple[Any, ...]] = []
|
|
678
|
+
if not self.enabled:
|
|
679
|
+
return did
|
|
680
|
+
|
|
681
|
+
# Health check on workers
|
|
682
|
+
connected_count = sum(1 for w in self.workers.values() if w.connected)
|
|
683
|
+
for url, worker in list(self.workers.items()):
|
|
684
|
+
if self.ingress_enabled and not worker.connected:
|
|
685
|
+
# Ensure thread is alive or restart
|
|
686
|
+
worker.start()
|
|
687
|
+
|
|
688
|
+
# Flush any queued egress items
|
|
689
|
+
sent_count = self.flush_egress()
|
|
690
|
+
if sent_count > 0:
|
|
691
|
+
did.append(('bridge', f'flushed {sent_count} egress events'))
|
|
692
|
+
|
|
693
|
+
did.append(('bridge', f'{connected_count}/{len(self.workers)} relays connected',
|
|
694
|
+
f'ingress={self.ingress_stored} egress={self.egress_sent}'))
|
|
695
|
+
return did
|
|
696
|
+
|
|
697
|
+
def state(self) -> BridgeStateReport:
|
|
698
|
+
with self._lock:
|
|
699
|
+
pending_egress = len(self.egress_queue)
|
|
700
|
+
res: BridgeStateReport = {
|
|
701
|
+
'enabled': self.enabled,
|
|
702
|
+
'relays_configured': len(self.relays),
|
|
703
|
+
'relays_connected': sum(1 for w in self.workers.values() if w.connected),
|
|
704
|
+
'ingress': {
|
|
705
|
+
'enabled': self.ingress_enabled,
|
|
706
|
+
'stored': self.ingress_stored,
|
|
707
|
+
'duplicates': self.ingress_duplicates,
|
|
708
|
+
'p2p_forward_failures': self.p2p_forward_failures,
|
|
709
|
+
'refused': self.ingress_refused,
|
|
710
|
+
'last_event_at': self.last_ingress_at,
|
|
711
|
+
},
|
|
712
|
+
'egress': {
|
|
713
|
+
'enabled': self.egress_enabled,
|
|
714
|
+
'sent': self.egress_sent,
|
|
715
|
+
'failed': self.egress_failed,
|
|
716
|
+
'pending': pending_egress,
|
|
717
|
+
'last_event_at': self.last_egress_at,
|
|
718
|
+
},
|
|
719
|
+
'relays': {
|
|
720
|
+
url: w.stats() for url, w in self.workers.items()
|
|
721
|
+
},
|
|
722
|
+
}
|
|
723
|
+
return res
|
|
724
|
+
|
|
725
|
+
def trouble(self) -> list[str]:
|
|
726
|
+
out: list[str] = []
|
|
727
|
+
if not self.enabled:
|
|
728
|
+
return out
|
|
729
|
+
|
|
730
|
+
now = self.clock()
|
|
731
|
+
if self.relays and not any(w.connected for w in self.workers.values()):
|
|
732
|
+
out.append(f'all {len(self.relays)} bridge relays are disconnected')
|
|
733
|
+
|
|
734
|
+
recent_refusals = self.refusals.within(self.TROUBLE_WINDOW, now, kind='ingress')
|
|
735
|
+
if len(recent_refusals) > 20:
|
|
736
|
+
out.append(f'{len(recent_refusals)} ingress events refused in the last 30 minutes')
|
|
737
|
+
|
|
738
|
+
return out
|
|
739
|
+
|
|
740
|
+
def publish_cmd(self, args: Mapping[str, Any]) -> Any:
|
|
741
|
+
ev = args.get('event')
|
|
742
|
+
if not isinstance(ev, dict) or not isinstance(ev.get('id'), str):
|
|
743
|
+
raise BridgeError('event must be a valid nostr event object')
|
|
744
|
+
relays = args.get('relays')
|
|
745
|
+
return self.publish(cast(NostrEvent, ev), target_relays=relays)
|
|
746
|
+
|
|
747
|
+
def sync_cmd(self, args: Mapping[str, Any]) -> Any:
|
|
748
|
+
filt = args.get('filter')
|
|
749
|
+
filters = [filt] if isinstance(filt, dict) else (filt if isinstance(filt, list) else self.ingress_filters)
|
|
750
|
+
target_relay = args.get('relay')
|
|
751
|
+
targets = [str(target_relay)] if target_relay else None
|
|
752
|
+
return self.sync_query(filters, target_relays=targets)
|
|
753
|
+
|
|
754
|
+
def reconcile_cmd(self, args: Mapping[str, Any]) -> Any:
|
|
755
|
+
url = args.get('relay') or (self.relays[0] if self.relays else None)
|
|
756
|
+
if not url:
|
|
757
|
+
raise BridgeError('no target relay specified for reconcile')
|
|
758
|
+
filter_spec = args.get('filter')
|
|
759
|
+
since = args.get('since')
|
|
760
|
+
until = args.get('until')
|
|
761
|
+
limit = args.get('limit') or 500
|
|
762
|
+
return self.reconcile_with_relay(str(url), filter_spec=filter_spec, since=since, until=until, limit=int(limit))
|
|
763
|
+
|
|
764
|
+
def add_relay_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
765
|
+
url = args.get('url')
|
|
766
|
+
if not isinstance(url, str) or not url.startswith(('ws://', 'wss://')):
|
|
767
|
+
raise BridgeError('url must be a ws:// or wss:// URL')
|
|
768
|
+
if url not in self.relays:
|
|
769
|
+
self.relays.append(url)
|
|
770
|
+
worker = self._add_worker(url)
|
|
771
|
+
return {'added': url, 'connected': worker.connected}
|
|
772
|
+
|
|
773
|
+
def remove_relay_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
774
|
+
url = args.get('url')
|
|
775
|
+
if not isinstance(url, str):
|
|
776
|
+
raise BridgeError('url must be a string')
|
|
777
|
+
if url in self.relays:
|
|
778
|
+
self.relays.remove(url)
|
|
779
|
+
self._remove_worker(url)
|
|
780
|
+
return {'removed': url}
|
|
781
|
+
|
|
782
|
+
def cursor_cmd(self) -> Any:
|
|
783
|
+
if hasattr(self.store, 'all_bridge_cursors'):
|
|
784
|
+
return self.store.all_bridge_cursors()
|
|
785
|
+
return []
|