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,656 @@
|
|
|
1
|
+
"""The relay face: a minimal NIP-01 relay over a MeshStore.
|
|
2
|
+
|
|
3
|
+
EVENT is verified, stored and fanned out to every live subscription that
|
|
4
|
+
matches; REQ answers from the store and stays open; CLOSE ends it. Ephemeral
|
|
5
|
+
kinds are fanned out and never stored, which is what lets kind 21059 gift
|
|
6
|
+
wraps be a transport. NIP-11 answers on GET. This is the seed of the node's
|
|
7
|
+
relay face (step 5), and the fixture the identity and transport tests run
|
|
8
|
+
against; NIP-77 frames come with the sync step.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, cast, Mapping, Optional, Sequence, TypedDict
|
|
18
|
+
from fleetmesh_core.storage import negentropy, store # noqa: E402
|
|
19
|
+
FACE_FUTURE_MAX_SECONDS: int = 900
|
|
20
|
+
from node.interfaces import EventLike, NostrEvent, NostrFilter # noqa: E402
|
|
21
|
+
from node.transport import ws # noqa: E402
|
|
22
|
+
from fleetmesh_core.crypto import giftwrap # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GossipTopicSummary(TypedDict):
|
|
26
|
+
topic: str
|
|
27
|
+
admin: str
|
|
28
|
+
members: int
|
|
29
|
+
mesh: int
|
|
30
|
+
expires: Any
|
|
31
|
+
roster_id: Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GossipViewReport(TypedDict):
|
|
35
|
+
enabled: bool
|
|
36
|
+
topics: list[GossipTopicSummary]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RelayLimitation(TypedDict):
|
|
40
|
+
max_subscriptions: int
|
|
41
|
+
max_message_length: int
|
|
42
|
+
max_subid_length: int
|
|
43
|
+
max_limit: int
|
|
44
|
+
auth_required: bool
|
|
45
|
+
payment_required: bool
|
|
46
|
+
restricted_writes: bool
|
|
47
|
+
min_pow_difficulty: int
|
|
48
|
+
created_at_upper_limit: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NIP11RelayInformation(TypedDict, total=False):
|
|
52
|
+
name: str
|
|
53
|
+
software: str
|
|
54
|
+
version: str
|
|
55
|
+
supported_nips: list[int]
|
|
56
|
+
limitation: RelayLimitation
|
|
57
|
+
pubkey: str
|
|
58
|
+
|
|
59
|
+
# What one client may make the face hold or do. sub_max left the grant
|
|
60
|
+
# ceilings because a peer never opens a subscription; a client does, and
|
|
61
|
+
# twenty is the old member advisory. An event the reference would store
|
|
62
|
+
# without complaint can still be too big to be honest about.
|
|
63
|
+
SUB_MAX: int = 20
|
|
64
|
+
# A subscribed client sits silent for as long as nothing matches its filter.
|
|
65
|
+
# The socket timeout used to be the idle limit, so every quiet client was
|
|
66
|
+
# dropped after a minute. Now a quiet client is pinged at each timeout and
|
|
67
|
+
# dropped only when it has answered nothing for this long.
|
|
68
|
+
IDLE_DROP: int = 180
|
|
69
|
+
EVENT_MAX_BYTES: int = 256 * 1024
|
|
70
|
+
# How far ahead of this node's clock an event may be dated and still be stored.
|
|
71
|
+
#
|
|
72
|
+
# The face had no bound at all, and an open one collects what that implies: on
|
|
73
|
+
# the alpha mesh the newest event held was dated three weeks into the future.
|
|
74
|
+
# A future timestamp is not a NIP-59 randomisation - that runs BACKWARDS, up to
|
|
75
|
+
# two days, by construction - so anything ahead of now is a bad clock or a
|
|
76
|
+
# client buying itself the top of every `created_at DESC` listing forever.
|
|
77
|
+
#
|
|
78
|
+
# Fifteen minutes rather than the protocol's own max_clock_skew_seconds. That
|
|
79
|
+
# 60 is the tolerance for judging a peer this node has a grant with, on a box
|
|
80
|
+
# it can expect to run NTP; this is the door onto the public internet, where a
|
|
81
|
+
# phone two minutes out is ordinary and refusing it would be the relay's fault,
|
|
82
|
+
# not the client's.
|
|
83
|
+
#
|
|
84
|
+
# There is deliberately NO lower bound. A gift wrap is supposed to be backdated
|
|
85
|
+
# and NIP-40 already says when a thing expires; a floor here would refuse the
|
|
86
|
+
# exact traffic NIP-59 asks clients to send.
|
|
87
|
+
FUTURE_MAX_SECONDS: int = FACE_FUTURE_MAX_SECONDS # one number; node/config/records.py owns it
|
|
88
|
+
SID_MAX: int = 64
|
|
89
|
+
# NIP-42: how far a kind 22242's created_at may sit from this relay's clock
|
|
90
|
+
# either way. The challenge is fresh per connection, so replay is already
|
|
91
|
+
# refused; the window is defence against a challenge string that leaked
|
|
92
|
+
# and a client whose clock is a day wrong, and it is the value the NIP names.
|
|
93
|
+
AUTH_WINDOW_SECONDS: int = 600
|
|
94
|
+
# The most events one REQ may be answered with, whatever it asked for.
|
|
95
|
+
#
|
|
96
|
+
# A filter with no `limit` used to mean "all of them", which is a promise the
|
|
97
|
+
# face cannot keep once it holds more than it can afford to hold in memory at
|
|
98
|
+
# once - and an open face decides how much that is by collecting. NIP-11 has a
|
|
99
|
+
# field for exactly this, so the cap is declared rather than silently applied,
|
|
100
|
+
# and a client that wants more pages with `until`.
|
|
101
|
+
REQ_MAX: int = 500
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _filters_ok(filters: Sequence[Any]) -> Optional[str]:
|
|
105
|
+
if not filters or not all(isinstance(f, dict) for f in filters):
|
|
106
|
+
return 'filters must be objects'
|
|
107
|
+
for f in filters:
|
|
108
|
+
for k in ('since', 'until', 'limit'):
|
|
109
|
+
if k in f and (not isinstance(f[k], int) or isinstance(f[k], bool) or f[k] < 0):
|
|
110
|
+
return '%s must be a non-negative integer' % k
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RelayServer:
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
backend: Optional[Any] = None,
|
|
118
|
+
host: str = '127.0.0.1',
|
|
119
|
+
port: int = 0,
|
|
120
|
+
name: str = 'meshnode',
|
|
121
|
+
clock: Optional[Callable[[], int]] = None,
|
|
122
|
+
pubkey: Optional[str] = None,
|
|
123
|
+
require_auth: bool = False,
|
|
124
|
+
challenge: Optional[str] = None,
|
|
125
|
+
future_seconds: int = FUTURE_MAX_SECONDS,
|
|
126
|
+
blob_store: Optional[Any] = None,
|
|
127
|
+
metrics_enabled: bool = True,
|
|
128
|
+
metrics_provider: Optional[Callable[[], str]] = None,
|
|
129
|
+
node: Optional[Any] = None,
|
|
130
|
+
grants: Optional[Any] = None,
|
|
131
|
+
host_router: Optional[Any] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.store: Any = backend if backend is not None else store.MemoryStore()
|
|
134
|
+
# The grant store the metrics page counts peers from; the node hands
|
|
135
|
+
# it in, a face started on its own has none.
|
|
136
|
+
self.grants: Optional[Any] = grants
|
|
137
|
+
self.blob_store: Optional[Any] = blob_store
|
|
138
|
+
self.metrics_enabled: bool = metrics_enabled
|
|
139
|
+
self.metrics_provider: Optional[Callable[[], str]] = metrics_provider
|
|
140
|
+
self.node: Optional[Any] = node
|
|
141
|
+
self.blossom: Optional[Any] = None
|
|
142
|
+
# Compartments that answer plain HTTP on this port (nsite) append a
|
|
143
|
+
# `(conn, req) -> bool` here in start(); the first True wins. Blossom
|
|
144
|
+
# is asked first because its paths are fixed; a hook routes by Host.
|
|
145
|
+
self.http_handlers: list[Callable[[socket.socket, ws.PlainHTTP], bool]] = []
|
|
146
|
+
if blob_store is not None:
|
|
147
|
+
from . import blossom
|
|
148
|
+
self.blossom = blossom.BlossomServer(blob_store, node_pk=pubkey, clock=clock)
|
|
149
|
+
self.name: str = name
|
|
150
|
+
self.pubkey: Optional[str] = pubkey # the node key: NIP-11's way of saying whose face this is
|
|
151
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
152
|
+
self.require_auth: bool = require_auth
|
|
153
|
+
self.future_seconds: int = int(future_seconds)
|
|
154
|
+
# One challenge per connection, made when the socket opens and gone
|
|
155
|
+
# when it closes. It used to be one string for the life of the
|
|
156
|
+
# process: a kind 22242 signed for it once was then good on every
|
|
157
|
+
# later connection, so an AUTH event that any relay operator had seen
|
|
158
|
+
# was a key to this one for as long as it ran. A `challenge` given to
|
|
159
|
+
# the constructor pins it for every connection, for tests only.
|
|
160
|
+
self._fixed_challenge: Optional[str] = challenge
|
|
161
|
+
self._challenges: dict[int, str] = {} # ws id -> challenge
|
|
162
|
+
self._subs: dict[int, dict[str, list[NostrFilter | Mapping[str, Any]]]] = {} # (ws id) -> {sid: filters}
|
|
163
|
+
self._conns: dict[int, ws.WebSocket] = {} # ws id -> ws
|
|
164
|
+
self._authed: dict[int, str] = {} # ws id -> authed pubkey
|
|
165
|
+
self._lock: threading.Lock = threading.Lock()
|
|
166
|
+
self.host_router: Optional[Any] = host_router
|
|
167
|
+
if host_router is not None:
|
|
168
|
+
self._server: Optional[ws.Server] = None
|
|
169
|
+
self._host: str = host
|
|
170
|
+
self._port: int = port
|
|
171
|
+
else:
|
|
172
|
+
self._server = ws.Server(self._handle, host, port, http_handler=self._http)
|
|
173
|
+
self._host = self._server.host
|
|
174
|
+
self._port = self._server.port
|
|
175
|
+
self._neg: dict[int, dict[str, negentropy.Negentropy]] = {} # ws id -> {subid: Negentropy}
|
|
176
|
+
self.accepted: int = 0
|
|
177
|
+
self.refused_future: int = 0
|
|
178
|
+
self.on_event_handlers: list[Callable[[NostrEvent], None]] = []
|
|
179
|
+
|
|
180
|
+
def add_event_handler(self, handler: Callable[[NostrEvent], None]) -> Callable[[], None]:
|
|
181
|
+
"""Call `handler` with every event this face accepts; returns the call that removes it.
|
|
182
|
+
|
|
183
|
+
A handler that raises is skipped for that event, never the face."""
|
|
184
|
+
if handler not in self.on_event_handlers:
|
|
185
|
+
self.on_event_handlers.append(handler)
|
|
186
|
+
|
|
187
|
+
def remove() -> None:
|
|
188
|
+
if handler in self.on_event_handlers:
|
|
189
|
+
self.on_event_handlers.remove(handler)
|
|
190
|
+
return remove
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def server(self) -> Any:
|
|
194
|
+
if self._server is not None:
|
|
195
|
+
return self._server
|
|
196
|
+
if self.host_router is not None:
|
|
197
|
+
srv = getattr(self.host_router, 'server', None) or getattr(self.host_router, '_server', None)
|
|
198
|
+
if srv is not None:
|
|
199
|
+
return srv
|
|
200
|
+
class _StoppedServerProxy:
|
|
201
|
+
host = self.host
|
|
202
|
+
port = self.port
|
|
203
|
+
return _StoppedServerProxy()
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def host(self) -> str:
|
|
207
|
+
if self._server is not None:
|
|
208
|
+
return self._server.host
|
|
209
|
+
if self.host_router is not None:
|
|
210
|
+
return self.host_router.host
|
|
211
|
+
return self._host
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def port(self) -> int:
|
|
215
|
+
if self._server is not None:
|
|
216
|
+
return self._server.port
|
|
217
|
+
if self.host_router is not None:
|
|
218
|
+
return self.host_router.port
|
|
219
|
+
return self._port
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def url(self) -> str:
|
|
223
|
+
if self._server is not None:
|
|
224
|
+
return self._server.url
|
|
225
|
+
if self.host_router is not None:
|
|
226
|
+
return self.host_router.url
|
|
227
|
+
return 'ws://%s:%d' % (self.host, self.port)
|
|
228
|
+
|
|
229
|
+
def start(self) -> RelayServer:
|
|
230
|
+
if self._server is not None:
|
|
231
|
+
self._server.start()
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
def stop(self) -> None:
|
|
235
|
+
if self._server is not None:
|
|
236
|
+
self._server.stop()
|
|
237
|
+
|
|
238
|
+
def handle_http_request(self, conn: socket.socket, req: ws.PlainHTTP) -> bool:
|
|
239
|
+
"""Handle an HTTP request. Returns True if handled, False if unhandled."""
|
|
240
|
+
if self.blossom and self.blossom.handle_request(conn, req):
|
|
241
|
+
return True
|
|
242
|
+
for handler in self.http_handlers:
|
|
243
|
+
if handler(conn, req):
|
|
244
|
+
return True
|
|
245
|
+
clean_path = req.path.split('?', 1)[0].rstrip('/')
|
|
246
|
+
headers = req.headers or {}
|
|
247
|
+
if clean_path in ('', '/nip11') or headers.get(b'accept', b'').startswith(b'application/nostr+json'):
|
|
248
|
+
body = json.dumps(self.nip11()).encode()
|
|
249
|
+
ws.http_response(conn, '200 OK', body, 'application/nostr+json')
|
|
250
|
+
return True
|
|
251
|
+
elif clean_path == '/metrics' and getattr(self, 'metrics_enabled', True):
|
|
252
|
+
body = self.render_metrics().encode('utf-8')
|
|
253
|
+
ws.http_response(conn, '200 OK', body, 'text/plain; version=0.0.4; charset=utf-8')
|
|
254
|
+
return True
|
|
255
|
+
elif clean_path == '/gossip':
|
|
256
|
+
body = json.dumps(self.gossip_view()).encode('utf-8')
|
|
257
|
+
ws.http_response(conn, '200 OK', body, 'application/json')
|
|
258
|
+
return True
|
|
259
|
+
elif clean_path == '/didcomm':
|
|
260
|
+
ws.http_response(conn, '501 Not Implemented',
|
|
261
|
+
b'{"error":"transport https declared, not implemented; use iroh, libp2p or nostr"}',
|
|
262
|
+
'application/json')
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
def mount_ingress(self, host: Any, owner: str = 'nostr') -> None:
|
|
267
|
+
"""Mount Nostr relay WebSocket and HTTP endpoints onto ProtocolHostRouter."""
|
|
268
|
+
host.mount_websocket('/', self._handle, owner=owner)
|
|
269
|
+
host.mount_route('/', self.handle_http_request, methods=('*',), owner=owner)
|
|
270
|
+
host.mount_route('/nip11', self.handle_http_request, methods=('GET',), owner=owner)
|
|
271
|
+
host.mount_route('/metrics', self.handle_http_request, methods=('GET',), owner=owner)
|
|
272
|
+
host.mount_route('/gossip', self.handle_http_request, methods=('GET',), owner=owner)
|
|
273
|
+
host.mount_route('/didcomm', self.handle_http_request, methods=('GET', 'POST'), owner=owner)
|
|
274
|
+
if self.blossom is not None:
|
|
275
|
+
host.mount_route('/upload', self.blossom.handle_request, methods=('PUT', 'HEAD', 'OPTIONS'), owner=owner)
|
|
276
|
+
host.mount_route('/list', self.blossom.handle_request, methods=('GET', 'HEAD', 'OPTIONS'), owner=owner)
|
|
277
|
+
host.mount_route('/mirror', self.blossom.handle_request, methods=('PUT', 'OPTIONS'), owner=owner)
|
|
278
|
+
|
|
279
|
+
# ---- one connection --------------------------------------------------
|
|
280
|
+
def _handle(self, sock: ws.WebSocket, path: str) -> None:
|
|
281
|
+
key = id(sock)
|
|
282
|
+
challenge = self._fixed_challenge or ('challenge-' + os.urandom(16).hex())
|
|
283
|
+
with self._lock:
|
|
284
|
+
self._conns[key], self._subs[key] = sock, {}
|
|
285
|
+
self._challenges[key] = challenge
|
|
286
|
+
if self.require_auth:
|
|
287
|
+
sock.send_text(json.dumps(['AUTH', challenge]))
|
|
288
|
+
try:
|
|
289
|
+
while True:
|
|
290
|
+
try:
|
|
291
|
+
raw = sock.recv_text()
|
|
292
|
+
except socket.timeout:
|
|
293
|
+
if sock.idle() > IDLE_DROP:
|
|
294
|
+
raise
|
|
295
|
+
sock.ping()
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
msg = json.loads(raw)
|
|
299
|
+
except ValueError:
|
|
300
|
+
sock.send_text(json.dumps(['NOTICE', 'not JSON']))
|
|
301
|
+
continue
|
|
302
|
+
if not isinstance(msg, list) or not msg:
|
|
303
|
+
continue
|
|
304
|
+
verb = msg[0]
|
|
305
|
+
sid = msg[1] if len(msg) > 1 else None
|
|
306
|
+
if verb in ('REQ', 'CLOSE', 'NEG-OPEN', 'NEG-MSG', 'NEG-CLOSE') and not (
|
|
307
|
+
isinstance(sid, str) and 0 < len(sid) <= SID_MAX):
|
|
308
|
+
sock.send_text(json.dumps(['NOTICE', 'subscription id must be a short string']))
|
|
309
|
+
continue
|
|
310
|
+
# NIP-01 verb dispatch. The arity guards of the old elif chain
|
|
311
|
+
# are the sequence patterns: a verb with too few members falls
|
|
312
|
+
# through to the same NOTICE an unknown verb gets, as before.
|
|
313
|
+
match msg:
|
|
314
|
+
case ['EVENT', ev, *_]:
|
|
315
|
+
self._on_event(sock, ev, key)
|
|
316
|
+
case ['REQ', str(req_sid), *filters]:
|
|
317
|
+
if not filters:
|
|
318
|
+
sock.send_text(json.dumps(['CLOSED', req_sid, 'invalid: filters required']))
|
|
319
|
+
continue
|
|
320
|
+
why = _filters_ok(filters)
|
|
321
|
+
if why is not None:
|
|
322
|
+
sock.send_text(json.dumps(['CLOSED', req_sid, 'invalid: ' + why]))
|
|
323
|
+
continue
|
|
324
|
+
with self._lock:
|
|
325
|
+
open_subs = len(self._subs.get(key, {}))
|
|
326
|
+
if open_subs >= SUB_MAX and req_sid not in self._subs.get(key, {}):
|
|
327
|
+
sock.send_text(json.dumps(['CLOSED', req_sid, 'blocked: too many subscriptions']))
|
|
328
|
+
continue
|
|
329
|
+
self._on_req(sock, key, req_sid, filters)
|
|
330
|
+
case ['AUTH', auth_ev, *_]:
|
|
331
|
+
self._on_auth(sock, key, auth_ev)
|
|
332
|
+
case ['CLOSE', str(close_sid), *_]:
|
|
333
|
+
with self._lock:
|
|
334
|
+
self._subs[key].pop(close_sid, None)
|
|
335
|
+
case ['NEG-OPEN', str(neg_sid), neg_filter, neg_msg, *_]:
|
|
336
|
+
self._neg_open(sock, key, neg_sid, neg_filter, neg_msg)
|
|
337
|
+
case ['NEG-MSG', str(neg_sid), neg_msg, *_]:
|
|
338
|
+
self._neg_msg(sock, key, neg_sid, neg_msg)
|
|
339
|
+
case ['NEG-CLOSE', str(neg_sid), *_]:
|
|
340
|
+
self._neg.get(key, {}).pop(neg_sid, None)
|
|
341
|
+
case _:
|
|
342
|
+
sock.send_text(json.dumps(['NOTICE', 'unknown verb']))
|
|
343
|
+
finally:
|
|
344
|
+
with self._lock:
|
|
345
|
+
self._conns.pop(key, None)
|
|
346
|
+
self._subs.pop(key, None)
|
|
347
|
+
self._authed.pop(key, None)
|
|
348
|
+
self._challenges.pop(key, None)
|
|
349
|
+
|
|
350
|
+
def _on_auth(self, sock: ws.WebSocket, key: int, ev: Any) -> None:
|
|
351
|
+
if not isinstance(ev, dict) or not giftwrap.verify_event(ev):
|
|
352
|
+
sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
|
|
353
|
+
False, 'invalid: signature or id does not verify']))
|
|
354
|
+
return
|
|
355
|
+
if ev.get('kind') != 22242:
|
|
356
|
+
sock.send_text(json.dumps(['OK', ev.get('id', ''), False, 'invalid: kind must be 22242']))
|
|
357
|
+
return
|
|
358
|
+
with self._lock:
|
|
359
|
+
expected = self._challenges.get(key)
|
|
360
|
+
# Compared for this connection only. id(ws) is reused once a socket
|
|
361
|
+
# is gone, which is why the entry is removed on disconnect rather
|
|
362
|
+
# than left to match a stranger's later connection.
|
|
363
|
+
has_challenge = expected is not None and any(
|
|
364
|
+
isinstance(t, list) and len(t) > 1 and t[0] == 'challenge' and t[1] == expected
|
|
365
|
+
for t in ev.get('tags', []))
|
|
366
|
+
if not has_challenge:
|
|
367
|
+
sock.send_text(json.dumps(['OK', ev['id'], False, 'invalid: challenge mismatch']))
|
|
368
|
+
return
|
|
369
|
+
created = ev.get('created_at')
|
|
370
|
+
if not isinstance(created, int) or abs(created - self.clock()) > AUTH_WINDOW_SECONDS:
|
|
371
|
+
sock.send_text(json.dumps(['OK', ev['id'], False,
|
|
372
|
+
'invalid: created_at is outside the %d second window' % AUTH_WINDOW_SECONDS]))
|
|
373
|
+
return
|
|
374
|
+
with self._lock:
|
|
375
|
+
self._authed[key] = ev['pubkey']
|
|
376
|
+
sock.send_text(json.dumps(['OK', ev['id'], True, '']))
|
|
377
|
+
|
|
378
|
+
def _on_event(self, sock: ws.WebSocket, ev: Any, key: int = 0) -> None:
|
|
379
|
+
if self.require_auth and key not in self._authed:
|
|
380
|
+
sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
|
|
381
|
+
False, 'auth-required: authentication required']))
|
|
382
|
+
return
|
|
383
|
+
if isinstance(ev, dict) and len(json.dumps(ev, separators=(',', ':'))) > EVENT_MAX_BYTES:
|
|
384
|
+
sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev.get('id'), str) else '', False,
|
|
385
|
+
'invalid: event is larger than %d bytes' % EVENT_MAX_BYTES]))
|
|
386
|
+
return
|
|
387
|
+
if not isinstance(ev, dict) or not giftwrap.verify_event(ev):
|
|
388
|
+
sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
|
|
389
|
+
False, 'invalid: signature or id does not verify']))
|
|
390
|
+
return
|
|
391
|
+
# A verified event has id, pubkey, created_at, kind and sig of the right
|
|
392
|
+
# types - the id is a hash over them and the check would have failed
|
|
393
|
+
# otherwise. verify_event tolerates an omitted tags/content as empty,
|
|
394
|
+
# and this face has always accepted such events, so the type is
|
|
395
|
+
# asserted here rather than checked again and made stricter.
|
|
396
|
+
event = cast(NostrEvent, ev)
|
|
397
|
+
now = self.clock()
|
|
398
|
+
# Checked after the signature, so a rejection is about an event that is
|
|
399
|
+
# genuinely someone's, and before the store, so nothing dated past the
|
|
400
|
+
# bound is ever written down.
|
|
401
|
+
created = ev.get('created_at')
|
|
402
|
+
if self.future_seconds and isinstance(created, int) and created > now + self.future_seconds:
|
|
403
|
+
# Counted before the answer goes out, the way `accepted` is. The
|
|
404
|
+
# other order means a client holding its OK can read the counter
|
|
405
|
+
# before this thread has reached it, so a caller that refuses two
|
|
406
|
+
# events and then asks how many were refused is told one. Rare
|
|
407
|
+
# enough to pass every local run and turn up on a loaded CI runner.
|
|
408
|
+
self.refused_future += 1
|
|
409
|
+
sock.send_text(json.dumps(['OK', ev['id'], False,
|
|
410
|
+
'invalid: created_at is %d seconds in the future, and this relay '
|
|
411
|
+
'accepts at most %d' % (created - now, self.future_seconds)]))
|
|
412
|
+
return
|
|
413
|
+
if store.is_ephemeral(ev['kind']):
|
|
414
|
+
result = 'ephemeral'
|
|
415
|
+
else:
|
|
416
|
+
result = self.store.insert_event(event, now)
|
|
417
|
+
# A client handed this to us directly, which is the only provenance
|
|
418
|
+
# that lets a coverage claim assert completeness.
|
|
419
|
+
if result == 'stored' and hasattr(self.store, 'note_origin'):
|
|
420
|
+
self.store.note_origin(ev['id'], self.store.LOCAL)
|
|
421
|
+
self.accepted += 1
|
|
422
|
+
sock.send_text(json.dumps(['OK', ev['id'], True, '' if result != 'duplicate' else 'duplicate: already have it']))
|
|
423
|
+
self._fanout(event)
|
|
424
|
+
for handler in list(self.on_event_handlers):
|
|
425
|
+
try:
|
|
426
|
+
handler(event)
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
def _fanout(self, ev: EventLike) -> None:
|
|
431
|
+
with self._lock:
|
|
432
|
+
targets = [(w, sid) for k, w in self._conns.items()
|
|
433
|
+
for sid, filters in self._subs.get(k, {}).items()
|
|
434
|
+
if any(store.matches(ev, f) for f in filters)]
|
|
435
|
+
if not targets:
|
|
436
|
+
return
|
|
437
|
+
# The event is serialised once and the subscription id spliced in per
|
|
438
|
+
# target; json.dumps(['EVENT', sid, ev]) with these separators is
|
|
439
|
+
# exactly this concatenation, so the frame bytes are unchanged.
|
|
440
|
+
# Measured 9-10x faster at 50 subscribers on a typical kind-1 event.
|
|
441
|
+
body = ',' + json.dumps(ev, separators=(',', ':')) + ']'
|
|
442
|
+
for w, sid in targets:
|
|
443
|
+
try:
|
|
444
|
+
w.send_text('["EVENT",' + json.dumps(sid) + body)
|
|
445
|
+
except OSError:
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
def _on_req(self, sock: ws.WebSocket, key: int, sid: str, filters: Sequence[NostrFilter | Mapping[str, Any]]) -> None:
|
|
449
|
+
if self.require_auth and key not in self._authed:
|
|
450
|
+
sock.send_text(json.dumps(['CLOSED', sid, 'auth-required: authentication required']))
|
|
451
|
+
return
|
|
452
|
+
now = self.clock()
|
|
453
|
+
# How many can leave, decided before anything is read. The filter used
|
|
454
|
+
# to be dropped here - items_range({}) - so every event in the time
|
|
455
|
+
# range was parsed to build the id list and parsed again to be matched,
|
|
456
|
+
# and the limit was applied to what survived. On a face holding 8,923
|
|
457
|
+
# events that is 200 MB of JSON per REQ, which is what was OOM-killing
|
|
458
|
+
# the public node every three hours.
|
|
459
|
+
asked = [f['limit'] for f in filters if isinstance(f.get('limit'), int)]
|
|
460
|
+
limit = min(min(asked) if asked else REQ_MAX, REQ_MAX)
|
|
461
|
+
# Registered before anything is replayed, and so before the EOSE that
|
|
462
|
+
# tells the client the subscription exists. The other order had the
|
|
463
|
+
# face send stored events, send the EOSE, and only then put the filters
|
|
464
|
+
# where `_fanout` looks: a client that waits for its EOSE and then
|
|
465
|
+
# causes an event - which is exactly what `relay.subscribe(ready=True)`
|
|
466
|
+
# documents as the way to know it is registered - could have that
|
|
467
|
+
# event fanned out to nobody in the window between the two lines. An
|
|
468
|
+
# ephemeral kind is then gone for good, because no relay stores one,
|
|
469
|
+
# and no timeout on the reader can recover it.
|
|
470
|
+
#
|
|
471
|
+
# The cost of this order is that an event arriving during the replay
|
|
472
|
+
# can go out live and appear in the replay too. That is a duplicate id
|
|
473
|
+
# on a subscription, which NIP-01 requires a client to tolerate and
|
|
474
|
+
# every reader here already does - a lost ephemeral event is not
|
|
475
|
+
# recoverable by anyone.
|
|
476
|
+
with self._lock:
|
|
477
|
+
self._subs[key][sid] = [
|
|
478
|
+
{**store.compiled(f), **{k: f[k] for k in ('since', 'until') if k in f}}
|
|
479
|
+
for f in filters
|
|
480
|
+
]
|
|
481
|
+
found: dict[str, NostrEvent] = {}
|
|
482
|
+
for f in filters:
|
|
483
|
+
since, until = f.get('since', 0), f.get('until', 2 ** 31)
|
|
484
|
+
for ev in self.store.newest_matching(f, since, until, now, limit):
|
|
485
|
+
found[ev['id']] = ev
|
|
486
|
+
# The newest `limit` of the union is a subset of each filter's newest
|
|
487
|
+
# `limit`, so merging the bounded answers and cutting again is the same
|
|
488
|
+
# set the unbounded version sent.
|
|
489
|
+
out = sorted(found.values(), key=lambda e: (-e['created_at'], e['id']))
|
|
490
|
+
for ev in out[:limit]:
|
|
491
|
+
sock.send_text(json.dumps(['EVENT', sid, ev], separators=(',', ':')))
|
|
492
|
+
sock.send_text(json.dumps(['EOSE', sid]))
|
|
493
|
+
|
|
494
|
+
# ---- NIP-77 ------------------------------------------------------------
|
|
495
|
+
def _neg_open(self, sock: ws.WebSocket, key: int, sid: str, filt: Any, initial_hex: Any) -> None:
|
|
496
|
+
"""NEG-OPEN: reconcile the filter's range with the client's first frame."""
|
|
497
|
+
now = self.clock()
|
|
498
|
+
if not isinstance(filt, dict) or _filters_ok([filt]) is not None or not isinstance(initial_hex, str):
|
|
499
|
+
sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: filter must be an object and the frame hex']))
|
|
500
|
+
return
|
|
501
|
+
try:
|
|
502
|
+
items = self.store.items_range(filt, filt.get('since', 0), filt.get('until', 2 ** 31), now)
|
|
503
|
+
neg = negentropy.Negentropy(items, is_client=False)
|
|
504
|
+
reply, _, _ = neg.reconcile(bytes.fromhex(initial_hex))
|
|
505
|
+
except negentropy.UnsupportedVersion:
|
|
506
|
+
sock.send_text(json.dumps(['NEG-MSG', sid, '%02x' % negentropy.VERSION]))
|
|
507
|
+
return
|
|
508
|
+
except (ValueError, TypeError, negentropy.Malformed) as e:
|
|
509
|
+
sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: %s' % e]))
|
|
510
|
+
return
|
|
511
|
+
self._neg.setdefault(key, {})[sid] = neg
|
|
512
|
+
sock.send_text(json.dumps(['NEG-MSG', sid, (reply or b'').hex()]))
|
|
513
|
+
|
|
514
|
+
def _neg_msg(self, sock: ws.WebSocket, key: int, sid: str, frame_hex: str) -> None:
|
|
515
|
+
neg = self._neg.get(key, {}).get(sid)
|
|
516
|
+
if neg is None:
|
|
517
|
+
sock.send_text(json.dumps(['NEG-ERR', sid, 'closed: no such negentropy subscription']))
|
|
518
|
+
return
|
|
519
|
+
try:
|
|
520
|
+
reply, _, _ = neg.reconcile(bytes.fromhex(frame_hex))
|
|
521
|
+
except (ValueError, TypeError, negentropy.Malformed) as e:
|
|
522
|
+
sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: %s' % e]))
|
|
523
|
+
return
|
|
524
|
+
sock.send_text(json.dumps(['NEG-MSG', sid, (reply or b'').hex()]))
|
|
525
|
+
|
|
526
|
+
# ---- HTTP, NIP-11, Blossom & Prometheus Metrics --------------------------
|
|
527
|
+
def render_metrics(self) -> str:
|
|
528
|
+
"""Render Prometheus / OpenMetrics exposition format."""
|
|
529
|
+
if self.metrics_provider is not None:
|
|
530
|
+
try:
|
|
531
|
+
return self.metrics_provider()
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
lines = [
|
|
536
|
+
'# HELP fleetmesh_events_stored_total Total events stored in local store.',
|
|
537
|
+
'# TYPE fleetmesh_events_stored_total counter',
|
|
538
|
+
]
|
|
539
|
+
stored_count = 0
|
|
540
|
+
if hasattr(self.store, 'count'):
|
|
541
|
+
try:
|
|
542
|
+
stored_count = self.store.count()
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
lines.append(f'fleetmesh_events_stored_total {stored_count}')
|
|
546
|
+
|
|
547
|
+
active_subs = 0
|
|
548
|
+
with self._lock:
|
|
549
|
+
active_conns = len(self._conns)
|
|
550
|
+
active_subs = sum(len(s) for s in self._subs.values())
|
|
551
|
+
|
|
552
|
+
lines.extend([
|
|
553
|
+
'# HELP fleetmesh_active_connections Current active WebSocket connections.',
|
|
554
|
+
'# TYPE fleetmesh_active_connections gauge',
|
|
555
|
+
f'fleetmesh_active_connections {active_conns}',
|
|
556
|
+
'# HELP fleetmesh_active_subscriptions Current active event subscriptions.',
|
|
557
|
+
'# TYPE fleetmesh_active_subscriptions gauge',
|
|
558
|
+
f'fleetmesh_active_subscriptions {active_subs}',
|
|
559
|
+
'# HELP fleetmesh_events_accepted_total Total valid events accepted through relay face.',
|
|
560
|
+
'# TYPE fleetmesh_events_accepted_total counter',
|
|
561
|
+
f'fleetmesh_events_accepted_total {self.accepted}',
|
|
562
|
+
'# HELP fleetmesh_events_refused_future_total Total events refused due to future timestamps.',
|
|
563
|
+
'# TYPE fleetmesh_events_refused_future_total counter',
|
|
564
|
+
f'fleetmesh_events_refused_future_total {self.refused_future}',
|
|
565
|
+
])
|
|
566
|
+
|
|
567
|
+
if self.blob_store is not None:
|
|
568
|
+
blob_count = 0
|
|
569
|
+
blob_bytes = 0
|
|
570
|
+
if hasattr(self.blob_store, 'count'):
|
|
571
|
+
try:
|
|
572
|
+
blob_count = self.blob_store.count()
|
|
573
|
+
except Exception:
|
|
574
|
+
pass
|
|
575
|
+
if hasattr(self.blob_store, 'total_bytes'):
|
|
576
|
+
try:
|
|
577
|
+
blob_bytes = self.blob_store.total_bytes()
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
lines.extend([
|
|
581
|
+
'# HELP fleetmesh_blobs_total Total content-addressed blobs in Blossom CAS.',
|
|
582
|
+
'# TYPE fleetmesh_blobs_total gauge',
|
|
583
|
+
f'fleetmesh_blobs_total {blob_count}',
|
|
584
|
+
'# HELP fleetmesh_blobs_bytes_total Total size in bytes of blobs in Blossom CAS.',
|
|
585
|
+
'# TYPE fleetmesh_blobs_bytes_total gauge',
|
|
586
|
+
f'fleetmesh_blobs_bytes_total {blob_bytes}',
|
|
587
|
+
])
|
|
588
|
+
|
|
589
|
+
if self.grants is not None and hasattr(self.grants, 'list'):
|
|
590
|
+
# Export peers categorized by trust tier
|
|
591
|
+
try:
|
|
592
|
+
by_tier: dict[str, int] = {}
|
|
593
|
+
for g in self.grants.list():
|
|
594
|
+
payload = g.get('payload')
|
|
595
|
+
t = payload.get('tier') if isinstance(payload, dict) else g.get('tier', 'unknown')
|
|
596
|
+
by_tier[str(t)] = by_tier.get(str(t), 0) + 1
|
|
597
|
+
lines.append('# HELP fleetmesh_peers_by_tier Number of peers categorized by trust tier.')
|
|
598
|
+
lines.append('# TYPE fleetmesh_peers_by_tier gauge')
|
|
599
|
+
for t, cnt in sorted(by_tier.items()):
|
|
600
|
+
lines.append(f'fleetmesh_peers_by_tier{{tier="{t}"}} {cnt}')
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
return '\n'.join(lines) + '\n'
|
|
605
|
+
|
|
606
|
+
def _http(self, conn: socket.socket, req_or_path: Any, headers: Optional[dict[bytes, bytes]] = None) -> None:
|
|
607
|
+
if isinstance(req_or_path, ws.PlainHTTP):
|
|
608
|
+
req = req_or_path
|
|
609
|
+
else:
|
|
610
|
+
path = str(req_or_path)
|
|
611
|
+
hdrs = headers or {}
|
|
612
|
+
req = ws.PlainHTTP(path=path, headers=hdrs, method='GET', sock=conn)
|
|
613
|
+
|
|
614
|
+
if not self.handle_http_request(conn, req):
|
|
615
|
+
ws.http_response(conn, '404 Not Found', b'{"error":"not found"}')
|
|
616
|
+
|
|
617
|
+
def gossip_view(self) -> GossipViewReport:
|
|
618
|
+
# gossip is an optional compartment: looked up on the node by name
|
|
619
|
+
# the way ctx.optional() does, absent when the node has not got one.
|
|
620
|
+
gossip = getattr(self.node, 'gossip', None) if self.node is not None else None
|
|
621
|
+
if gossip is None:
|
|
622
|
+
return {'enabled': False, 'topics': []}
|
|
623
|
+
rows = gossip.command('topics', {})
|
|
624
|
+
return {'enabled': True,
|
|
625
|
+
'topics': [{'topic': r['topic'], 'admin': r['admin'], 'members': len(r['members']),
|
|
626
|
+
'mesh': len(r['mesh']), 'expires': r['expires'], 'roster_id': r['roster_id']}
|
|
627
|
+
for r in rows]}
|
|
628
|
+
|
|
629
|
+
def nip11(self) -> NIP11RelayInformation:
|
|
630
|
+
from fleetmesh_core.identity import identity
|
|
631
|
+
doc: NIP11RelayInformation = {
|
|
632
|
+
'name': self.name,
|
|
633
|
+
'software': identity.FLEETMESH.rstrip('/'),
|
|
634
|
+
'version': '0.0.1',
|
|
635
|
+
'supported_nips': [1, 11, 40, 59, 77],
|
|
636
|
+
# What the face enforces, in NIP-11's own words, so a NIP-66
|
|
637
|
+
# event about it can carry the requirement tags honestly.
|
|
638
|
+
'limitation': {
|
|
639
|
+
'max_subscriptions': SUB_MAX,
|
|
640
|
+
'max_message_length': EVENT_MAX_BYTES,
|
|
641
|
+
'max_subid_length': SID_MAX,
|
|
642
|
+
'max_limit': REQ_MAX,
|
|
643
|
+
'auth_required': self.require_auth,
|
|
644
|
+
'payment_required': False,
|
|
645
|
+
'restricted_writes': False,
|
|
646
|
+
'min_pow_difficulty': 0,
|
|
647
|
+
# NIP-11 states these as absolute timestamps, so it is computed
|
|
648
|
+
# rather than declared: a client reads the door this node has
|
|
649
|
+
# open right now. No lower limit is published because none is
|
|
650
|
+
# enforced - see FUTURE_MAX_SECONDS.
|
|
651
|
+
'created_at_upper_limit': self.clock() + self.future_seconds,
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
if self.pubkey:
|
|
655
|
+
doc['pubkey'] = self.pubkey
|
|
656
|
+
return doc
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""p2panda protocol suite for Fleetmesh.
|
|
2
|
+
|
|
3
|
+
Native implementation of the p2panda peer-to-peer data protocol:
|
|
4
|
+
- Canonical CBOR deterministic encoding (RFC 8949)
|
|
5
|
+
- Ed25519 author identity & cryptographic verification
|
|
6
|
+
- Append-only multi-writer operation log persistence
|
|
7
|
+
- Transport rung integration with TransportRouter ladder
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .identity import P2PandaIdentityAdapter
|
|
12
|
+
from .package import P2PandaProtocolPackage
|
|
13
|
+
from .rung import P2PandaRung
|
|
14
|
+
from .store import P2PandaLogStore, P2PandaStoreError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'P2PandaIdentityAdapter',
|
|
18
|
+
'P2PandaLogStore',
|
|
19
|
+
'P2PandaProtocolPackage',
|
|
20
|
+
'P2PandaRung',
|
|
21
|
+
'P2PandaStoreError',
|
|
22
|
+
]
|