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,283 @@
|
|
|
1
|
+
"""netfetch: the one HTTP fetch helper every outbound request in node/ goes through.
|
|
2
|
+
|
|
3
|
+
Why this exists. The node used to call urllib.request.urlopen() directly from
|
|
4
|
+
half a dozen places -- compute.resolve_blob (URL chosen by the job requester),
|
|
5
|
+
the Blossom /mirror handler (URL chosen by whoever holds an auth event),
|
|
6
|
+
BlossomClient (peer servers), the Cashu mint client and the watcher. Each site
|
|
7
|
+
re-invented the same three mistakes:
|
|
8
|
+
|
|
9
|
+
1. no scheme check, so ``file:///etc/passwd`` or ``ftp://`` were accepted by
|
|
10
|
+
urllib's default opener;
|
|
11
|
+
2. no address check, so a requester could point the node at
|
|
12
|
+
``http://127.0.0.1:<port>/`` (its own control face) or at the cloud
|
|
13
|
+
metadata endpoint ``169.254.169.254`` -- classic SSRF;
|
|
14
|
+
3. ``resp.read()`` with no cap, so a hostile server could hand back gigabytes
|
|
15
|
+
and exhaust memory.
|
|
16
|
+
|
|
17
|
+
This module fixes them once. ``fetch()`` allows only http/https, resolves the
|
|
18
|
+
host and refuses any address that is loopback, link-local, private (RFC 1918
|
|
19
|
+
and ULA), multicast, reserved or unspecified -- including IPv4-mapped IPv6
|
|
20
|
+
forms -- unless the caller explicitly opts in with ``allow_private=True``
|
|
21
|
+
(appropriate only for operator-configured URLs such as a local mint or a LAN
|
|
22
|
+
CI API, never for requester-supplied ones). Redirects are re-checked against
|
|
23
|
+
the same policy, so a public host cannot bounce us into the LAN. Bodies are
|
|
24
|
+
read to ``max_bytes + 1`` and anything beyond raises ``FetchTooLarge``; a
|
|
25
|
+
Content-Length larger than the cap is refused before a single body byte is
|
|
26
|
+
read.
|
|
27
|
+
|
|
28
|
+
Note on naming: this module is deliberately NOT called ``http.py``. Scripts
|
|
29
|
+
run from inside ``node/`` (``python3 node/demo.py``) put ``node/`` first on
|
|
30
|
+
``sys.path``, and a ``node/http.py`` would shadow the stdlib ``http`` package
|
|
31
|
+
that ``http.server``, ``urllib`` and ``ws`` depend on.
|
|
32
|
+
|
|
33
|
+
Note on DNS rebinding: the address check resolves the hostname once and then
|
|
34
|
+
lets urllib resolve it again for the actual connection. A malicious resolver
|
|
35
|
+
that answers differently on the second lookup could still reach a private
|
|
36
|
+
address. Closing that hole properly means connecting to the vetted IP and
|
|
37
|
+
sending the hostname in Host/SNI ourselves; that is more surgery than a
|
|
38
|
+
stdlib opener allows cleanly, so it is recorded here rather than pretended
|
|
39
|
+
away. Requester-supplied URLs are additionally gated by SHA-256 verification
|
|
40
|
+
at every call site, which limits what a rebinding attacker can learn.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import http.client
|
|
45
|
+
import ipaddress
|
|
46
|
+
import socket
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.parse
|
|
49
|
+
import urllib.request
|
|
50
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
51
|
+
|
|
52
|
+
DEFAULT_TIMEOUT: float = 10.0
|
|
53
|
+
USER_AGENT: str = 'fleetmesh-node/1.0'
|
|
54
|
+
MAX_REDIRECTS: int = 5
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FetchError(Exception):
|
|
58
|
+
"""Base class: anything that stops fetch() returning a body."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FetchRefused(FetchError):
|
|
62
|
+
"""The URL was refused by policy (scheme, private address, redirect target)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FetchTooLarge(FetchError):
|
|
66
|
+
"""The body (declared or actual) exceeds max_bytes."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FetchHTTPError(FetchError):
|
|
70
|
+
"""The server answered with a non-2xx status. ``code`` and ``body`` are kept
|
|
71
|
+
so callers that care (404 means 'not there', mint error JSON) can inspect
|
|
72
|
+
them without the raw urllib exception leaking through."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, code: int, reason: str, body: bytes = b'', headers: Optional[Mapping[str, str]] = None) -> None:
|
|
75
|
+
super().__init__('HTTP %d: %s' % (code, reason))
|
|
76
|
+
self.code = code
|
|
77
|
+
self.reason = reason
|
|
78
|
+
self.body = body
|
|
79
|
+
self.headers = dict(headers or {})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FetchNetworkError(FetchError):
|
|
83
|
+
"""DNS, connect, TLS, timeout or protocol failure."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_forbidden_address(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
87
|
+
"""True for any address a requester must not be able to make the node talk to.
|
|
88
|
+
|
|
89
|
+
IPv4-mapped IPv6 (``::ffff:127.0.0.1``) is unwrapped first so the IPv4
|
|
90
|
+
policy applies; otherwise ``ipaddress`` classifies it only by the IPv6
|
|
91
|
+
rules and, e.g., ``::ffff:10.0.0.1`` slips through as 'global'.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
94
|
+
ip = ip.ipv4_mapped
|
|
95
|
+
return (
|
|
96
|
+
ip.is_loopback
|
|
97
|
+
or ip.is_link_local
|
|
98
|
+
or ip.is_private
|
|
99
|
+
or ip.is_multicast
|
|
100
|
+
or ip.is_unspecified
|
|
101
|
+
or ip.is_reserved
|
|
102
|
+
or not ip.is_global
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _private_exempt(host: str, addrs: Sequence[str], allow_private: Any) -> bool:
|
|
107
|
+
"""Is this destination exempt from the address policy?
|
|
108
|
+
|
|
109
|
+
``allow_private`` is either a bool (True: anything goes -- operator URLs
|
|
110
|
+
only) or a collection of hostnames / IP literals. The collection form
|
|
111
|
+
exempts exactly those destinations and nothing else, so an operator who
|
|
112
|
+
runs a mint on 127.0.0.1 does not thereby open the whole LAN, and a
|
|
113
|
+
redirect from the exempt host to some other private address is still
|
|
114
|
+
refused.
|
|
115
|
+
"""
|
|
116
|
+
if allow_private is True:
|
|
117
|
+
return True
|
|
118
|
+
if not allow_private:
|
|
119
|
+
return False
|
|
120
|
+
exempt = {str(h).lower() for h in allow_private}
|
|
121
|
+
if host.lower() in exempt:
|
|
122
|
+
return True
|
|
123
|
+
return bool(addrs) and all(a in exempt for a in addrs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_url(url: str, *, allow_private: Any = False) -> urllib.parse.SplitResult:
|
|
127
|
+
"""Validate scheme and destination address; return the split URL.
|
|
128
|
+
|
|
129
|
+
Raises FetchRefused for anything that fails policy. Resolution failures
|
|
130
|
+
are FetchNetworkError: a name that does not resolve is not an attack,
|
|
131
|
+
just an unreachable host, and callers treat it like a connection error.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
parts = urllib.parse.urlsplit(str(url))
|
|
135
|
+
except ValueError as e:
|
|
136
|
+
raise FetchRefused('malformed URL: %s' % e)
|
|
137
|
+
if parts.scheme not in ('http', 'https'):
|
|
138
|
+
raise FetchRefused('scheme %r not allowed (http/https only)' % parts.scheme)
|
|
139
|
+
host = parts.hostname
|
|
140
|
+
if not host:
|
|
141
|
+
raise FetchRefused('URL has no host')
|
|
142
|
+
if allow_private is True:
|
|
143
|
+
return parts
|
|
144
|
+
try:
|
|
145
|
+
port = parts.port or (443 if parts.scheme == 'https' else 80)
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
raise FetchRefused('bad port: %s' % e)
|
|
148
|
+
# A literal address needs no DNS; check it directly (also catches
|
|
149
|
+
# bracketed IPv6 literals, which urlsplit already unbrackets).
|
|
150
|
+
try:
|
|
151
|
+
literal = ipaddress.ip_address(host)
|
|
152
|
+
except ValueError:
|
|
153
|
+
literal = None
|
|
154
|
+
if literal is not None:
|
|
155
|
+
if _is_forbidden_address(literal) and not _private_exempt(host, [host], allow_private):
|
|
156
|
+
raise FetchRefused('address %s is not a public address' % host)
|
|
157
|
+
return parts
|
|
158
|
+
try:
|
|
159
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
160
|
+
except socket.gaierror as e:
|
|
161
|
+
raise FetchNetworkError('cannot resolve %s: %s' % (host, e))
|
|
162
|
+
if not infos:
|
|
163
|
+
raise FetchNetworkError('cannot resolve %s' % host)
|
|
164
|
+
addrs = [str(info[4][0]).split('%', 1)[0] for info in infos]
|
|
165
|
+
if _private_exempt(host, addrs, allow_private):
|
|
166
|
+
return parts
|
|
167
|
+
# Every answer must pass: a name with one public and one private A record
|
|
168
|
+
# would otherwise be a coin-flip on which the socket layer picks.
|
|
169
|
+
for addr in addrs:
|
|
170
|
+
try:
|
|
171
|
+
ip = ipaddress.ip_address(addr)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise FetchRefused('unparseable resolved address %r for %s' % (addr, host))
|
|
174
|
+
if _is_forbidden_address(ip):
|
|
175
|
+
raise FetchRefused('%s resolves to non-public address %s' % (host, addr))
|
|
176
|
+
return parts
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _CheckedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
180
|
+
"""Re-run check_url() on every redirect target.
|
|
181
|
+
|
|
182
|
+
urllib follows 3xx by default and would happily hop from a vetted public
|
|
183
|
+
host to ``http://127.0.0.1/``; the first request's check says nothing
|
|
184
|
+
about where the chain ends.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
max_redirections = MAX_REDIRECTS
|
|
188
|
+
|
|
189
|
+
def __init__(self, allow_private: bool) -> None:
|
|
190
|
+
super().__init__()
|
|
191
|
+
self.allow_private = allow_private
|
|
192
|
+
|
|
193
|
+
def http_error_302(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
|
194
|
+
# Hook the status handler rather than redirect_request(): the stdlib
|
|
195
|
+
# runs its own scheme check (raising a plain HTTPError) *before*
|
|
196
|
+
# redirect_request is consulted, and we want our FetchRefused to be
|
|
197
|
+
# the answer for every disallowed target, not just private hosts.
|
|
198
|
+
loc = headers.get('location') or headers.get('uri')
|
|
199
|
+
if loc:
|
|
200
|
+
newurl = urllib.parse.urljoin(req.full_url, loc.split(' ', 1)[0].strip())
|
|
201
|
+
check_url(newurl, allow_private=self.allow_private)
|
|
202
|
+
return super().http_error_302(req, fp, code, msg, headers)
|
|
203
|
+
|
|
204
|
+
http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def fetch(
|
|
208
|
+
url: str,
|
|
209
|
+
*,
|
|
210
|
+
max_bytes: int,
|
|
211
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
212
|
+
allow_private: Any = False,
|
|
213
|
+
method: str = 'GET',
|
|
214
|
+
data: Optional[bytes] = None,
|
|
215
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
216
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
217
|
+
"""Fetch ``url`` under the policy described in the module docstring.
|
|
218
|
+
|
|
219
|
+
Returns ``(body, headers)`` where headers is a plain dict with the
|
|
220
|
+
original header names. ``max_bytes`` is mandatory on purpose: every
|
|
221
|
+
caller must state how much it is prepared to hold in memory.
|
|
222
|
+
|
|
223
|
+
``allow_private`` is False (full policy), True (no address policy at
|
|
224
|
+
all: operator-configured URLs only) or a collection of hostnames / IP
|
|
225
|
+
literals that are exempt while everything else -- including redirect
|
|
226
|
+
targets -- still has to be public.
|
|
227
|
+
"""
|
|
228
|
+
if max_bytes is None or int(max_bytes) < 0:
|
|
229
|
+
raise ValueError('max_bytes must be a non-negative int')
|
|
230
|
+
max_bytes = int(max_bytes)
|
|
231
|
+
check_url(url, allow_private=allow_private)
|
|
232
|
+
|
|
233
|
+
hdrs = {'User-Agent': USER_AGENT}
|
|
234
|
+
if headers:
|
|
235
|
+
hdrs.update(headers)
|
|
236
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper())
|
|
237
|
+
opener = urllib.request.build_opener(_CheckedRedirectHandler(allow_private))
|
|
238
|
+
try:
|
|
239
|
+
resp = opener.open(req, timeout=timeout)
|
|
240
|
+
except FetchError:
|
|
241
|
+
# Raised from inside the redirect handler; urllib does not wrap it.
|
|
242
|
+
raise
|
|
243
|
+
except urllib.error.HTTPError as e:
|
|
244
|
+
# Bound the error body too; an error page can be as hostile as a 200.
|
|
245
|
+
try:
|
|
246
|
+
body = e.read(max_bytes + 1)
|
|
247
|
+
except Exception:
|
|
248
|
+
body = b''
|
|
249
|
+
finally:
|
|
250
|
+
e.close() # the error response holds the socket until it is closed
|
|
251
|
+
if len(body) > max_bytes:
|
|
252
|
+
raise FetchTooLarge('error body exceeds %d bytes' % max_bytes)
|
|
253
|
+
raise FetchHTTPError(e.code, str(e.reason), body, dict(e.headers.items()) if e.headers else {})
|
|
254
|
+
except urllib.error.URLError as e:
|
|
255
|
+
# urllib wraps the redirect handler's refusal in URLError in some
|
|
256
|
+
# code paths; unwrap so callers see the policy error, not 'network'.
|
|
257
|
+
if isinstance(e.reason, FetchError):
|
|
258
|
+
raise e.reason
|
|
259
|
+
raise FetchNetworkError(str(e.reason))
|
|
260
|
+
except (socket.timeout, TimeoutError) as e:
|
|
261
|
+
raise FetchNetworkError('timeout: %s' % e)
|
|
262
|
+
except (http.client.HTTPException, OSError, ValueError) as e:
|
|
263
|
+
raise FetchNetworkError('%s: %s' % (type(e).__name__, e))
|
|
264
|
+
|
|
265
|
+
with resp:
|
|
266
|
+
resp_headers = dict(resp.headers.items())
|
|
267
|
+
clen = resp.headers.get('Content-Length')
|
|
268
|
+
# A HEAD response's Content-Length describes the body a GET *would*
|
|
269
|
+
# carry, not bytes on this connection, so the pre-check only applies
|
|
270
|
+
# to methods that actually return a body.
|
|
271
|
+
if clen is not None and method.upper() != 'HEAD':
|
|
272
|
+
try:
|
|
273
|
+
if int(clen) > max_bytes:
|
|
274
|
+
raise FetchTooLarge('Content-Length %s exceeds %d bytes' % (clen, max_bytes))
|
|
275
|
+
except ValueError:
|
|
276
|
+
pass # garbage Content-Length: fall through to the read cap
|
|
277
|
+
try:
|
|
278
|
+
body = resp.read(max_bytes + 1)
|
|
279
|
+
except (http.client.HTTPException, OSError) as e:
|
|
280
|
+
raise FetchNetworkError('read failed: %s: %s' % (type(e).__name__, e))
|
|
281
|
+
if len(body) > max_bytes:
|
|
282
|
+
raise FetchTooLarge('body exceeds %d bytes' % max_bytes)
|
|
283
|
+
return body, resp_headers
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The relay rung: DIDComm JWEs in NIP-59 gift wraps over nostr relays.
|
|
2
|
+
|
|
3
|
+
One rung of the transport ladder (router.py decides the order; this package
|
|
4
|
+
never does). It is the rung that always exists, so the router delegates its
|
|
5
|
+
inbox, thread waiters and acknowledgment ledger to it. Everything that knows
|
|
6
|
+
a relay, a wrap kind or a subscription filter lives here and nowhere else in
|
|
7
|
+
the node:
|
|
8
|
+
|
|
9
|
+
core.py RelayRung - the object, its state, and how it is built
|
|
10
|
+
wire.py wrap/fragment policy: which kind, which created_at, how big
|
|
11
|
+
outbound.py send, request/converse, thread waiters, where a stored wrap goes
|
|
12
|
+
listen.py one subscription per relay, reconnects, readiness
|
|
13
|
+
inbound.py unwrap, dedup, reassemble, open the envelope, deliver
|
|
14
|
+
ledger.py please_ack / ack: what was sent and is still owed an answer
|
|
15
|
+
shape.py the Protocol the mixins share
|
|
16
|
+
|
|
17
|
+
`NostrTransport` is the historical name of RelayRung and stays importable.
|
|
18
|
+
"""
|
|
19
|
+
from .core import RelayRung
|
|
20
|
+
|
|
21
|
+
NostrTransport = RelayRung
|
|
22
|
+
|
|
23
|
+
__all__ = ['RelayRung', 'NostrTransport']
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Per-relay cooldown after a relay says it is being asked too much.
|
|
2
|
+
|
|
3
|
+
A public relay answers an event it will not take with an OK false whose
|
|
4
|
+
reason is prefixed as NIP-01 suggests: `rate-limited:` and `banned:` are the
|
|
5
|
+
two that mean "stop sending for a while", and a handshake refused with HTTP
|
|
6
|
+
429 means the same before a word is spoken - as does a 503, which is what a
|
|
7
|
+
relay that has banned the address answers once it stops talking at all. Publishing on regardless turns a
|
|
8
|
+
minute of rate-limiting into a ban, and three nodes behind one address did
|
|
9
|
+
exactly that within a minute of starting. The rung therefore holds such a
|
|
10
|
+
relay out of publishing for a window that doubles on each repeat, keeps
|
|
11
|
+
listening on it, and records the one fault that matters - that the relay
|
|
12
|
+
was cooled, until when, and why - instead of one fault per refused wrap.
|
|
13
|
+
|
|
14
|
+
Pure bookkeeping over an injected clock; no I/O, so a test drives it with
|
|
15
|
+
reasons alone.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Callable, Optional, TypedDict
|
|
22
|
+
|
|
23
|
+
THROTTLE_PREFIXES: tuple[str, ...] = ('rate-limited', 'banned')
|
|
24
|
+
_HTTP_429 = re.compile(r'\bHTTP/\d(?:\.\d)? (?:429|503)\b') # 429 and 503 at the handshake
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cooling(TypedDict):
|
|
28
|
+
"""A relay held out of publishing: until when, why, and how many times running."""
|
|
29
|
+
until: int
|
|
30
|
+
why: str
|
|
31
|
+
strikes: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_throttle(reason: str) -> bool:
|
|
35
|
+
"""Whether an OK reason or connection error means 'you, specifically, stop for now'."""
|
|
36
|
+
head = reason.split(':', 1)[0].strip().lower()
|
|
37
|
+
return head in THROTTLE_PREFIXES or bool(_HTTP_429.search(reason))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RelayCooldown:
|
|
41
|
+
"""Which relays are held, for how long, and the doubling that decides it."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, clock: Callable[[], int], first: int = 60, longest: int = 900) -> None:
|
|
44
|
+
self.clock: Callable[[], int] = clock
|
|
45
|
+
self.first: int = first # the first window, seconds
|
|
46
|
+
self.longest: int = longest # the cap the doubling stops at
|
|
47
|
+
self._held: dict[str, Cooling] = {}
|
|
48
|
+
self._lock: threading.Lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
def refused(self, url: str, reason: str) -> Optional[Cooling]:
|
|
51
|
+
"""Note a refusal. Returns the Cooling it started when the reason was a
|
|
52
|
+
throttle and the relay was not already held; None otherwise."""
|
|
53
|
+
if not is_throttle(reason):
|
|
54
|
+
return None
|
|
55
|
+
now = self.clock()
|
|
56
|
+
with self._lock:
|
|
57
|
+
held = self._held.get(url)
|
|
58
|
+
if held is not None and held['until'] > now:
|
|
59
|
+
return None # already cooling; the refusal is the old one echoing
|
|
60
|
+
strikes = (held['strikes'] + 1) if held is not None else 1
|
|
61
|
+
window = min(self.longest, self.first * (2 ** (strikes - 1)))
|
|
62
|
+
entry = Cooling(until=now + window, why=reason, strikes=strikes)
|
|
63
|
+
self._held[url] = entry
|
|
64
|
+
return entry
|
|
65
|
+
|
|
66
|
+
def accepted(self, url: str) -> None:
|
|
67
|
+
"""An accepted publish after a cooling ended: the next strike starts over."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
held = self._held.get(url)
|
|
70
|
+
if held is not None and held['until'] <= self.clock():
|
|
71
|
+
del self._held[url]
|
|
72
|
+
|
|
73
|
+
def holding(self, url: str) -> Optional[Cooling]:
|
|
74
|
+
"""The Cooling on a relay if it is still in force."""
|
|
75
|
+
with self._lock:
|
|
76
|
+
held = self._held.get(url)
|
|
77
|
+
return held if held is not None and held['until'] > self.clock() else None
|
|
78
|
+
|
|
79
|
+
def open(self, urls: list[str]) -> list[str]:
|
|
80
|
+
"""The relays in `urls` not currently held, in order."""
|
|
81
|
+
return [u for u in urls if self.holding(u) is None]
|
|
82
|
+
|
|
83
|
+
def state(self) -> dict[str, Cooling]:
|
|
84
|
+
"""Every relay held right now."""
|
|
85
|
+
now = self.clock()
|
|
86
|
+
with self._lock:
|
|
87
|
+
return {u: dict(c) for u, c in self._held.items() if c['until'] > now} # type: ignore[misc]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""RelayRung: the relay rung's state and construction. Behaviour is in the
|
|
2
|
+
sibling mixins; this file is what an operator or a test instantiates.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import collections
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Counter, Deque, Mapping, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from ...interfaces import NostrEvent, RelayHealthRow
|
|
13
|
+
from .. import const
|
|
14
|
+
from ..ladder import Fault, FaultLog
|
|
15
|
+
from .cooldown import RelayCooldown
|
|
16
|
+
from ..const import Inbound
|
|
17
|
+
from . import wire
|
|
18
|
+
from .types import AckVerdict, AwaitingEntry, Partial
|
|
19
|
+
from .inbound import InboundMixin
|
|
20
|
+
from .ledger import LedgerMixin
|
|
21
|
+
from .listen import ListenMixin
|
|
22
|
+
from .outbound import OutboundMixin
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RelayRung(OutboundMixin, ListenMixin, InboundMixin, LedgerMixin):
|
|
26
|
+
scheme: str = 'nostr'
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
keystore: Any,
|
|
31
|
+
peers: Any,
|
|
32
|
+
relay_urls: Sequence[str],
|
|
33
|
+
clock: Optional[Callable[[], int]] = None,
|
|
34
|
+
timeout: float = 15.0,
|
|
35
|
+
seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = None,
|
|
36
|
+
ack_store: Optional[Any] = None,
|
|
37
|
+
seen_max: int = const.SEEN_MAX,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.ks: Any = keystore
|
|
40
|
+
self.seen_max: int = seen_max # wrap ids remembered for dedup before the oldest is forgotten
|
|
41
|
+
self.peers: Any = peers
|
|
42
|
+
# Stored messages sent and not yet acknowledged, by message id. On
|
|
43
|
+
# disk when the node backs it with its accounts (put_awaiting /
|
|
44
|
+
# drop_awaiting / awaiting), because the peer may be offline longer
|
|
45
|
+
# than this process lives and the question survives a restart.
|
|
46
|
+
self.ack_store: Optional[Any] = ack_store
|
|
47
|
+
self.awaiting: dict[str, AwaitingEntry] = {}
|
|
48
|
+
if ack_store is not None:
|
|
49
|
+
for mid, peer, mtype, at, relays in ack_store.awaiting():
|
|
50
|
+
self.awaiting[mid] = AwaitingEntry(peer=peer, type=mtype, at=at, relays=relays, heard_at=None)
|
|
51
|
+
self.heard: dict[str, int] = {} # peer -> when it was last heard from
|
|
52
|
+
self.acked: Counter[str] = collections.Counter() # type -> stored messages acknowledged
|
|
53
|
+
self.acks_in: int = 0
|
|
54
|
+
self.acks_out: int = 0
|
|
55
|
+
self.unacked: Deque[AckVerdict] = collections.deque(maxlen=const.DROPPED_KEPT) # verdicts, newest last
|
|
56
|
+
self._unacked_new: list[AckVerdict] = [] # verdicts not yet taken by the compartment
|
|
57
|
+
self._acks: queue.Queue[tuple[str, str]] = queue.Queue() # (message id, peer) to acknowledge
|
|
58
|
+
# Stored wraps (kind 1059) are replayed by relays on every reconnect,
|
|
59
|
+
# for two days; a node that forgets what it opened across a restart
|
|
60
|
+
# would act on an old terminate after re-peering. The store is a
|
|
61
|
+
# pair (has(wid), add(wid, at)) the node backs with its accounts.
|
|
62
|
+
self.seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = seen_store
|
|
63
|
+
self.wraps_in: Counter[Optional[int]] = collections.Counter() # inbound wraps by kind
|
|
64
|
+
self.wraps_seen_again: Counter[Optional[int]] = collections.Counter() # and those a dedup discarded
|
|
65
|
+
self.relays: list[str] = list(relay_urls)
|
|
66
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
67
|
+
# peer -> (its kind 10050 relays, when read). Read once an hour, not
|
|
68
|
+
# per message: a list that changed under us costs one hour of wraps
|
|
69
|
+
# going to the old inbox, which the relay replay horizon covers.
|
|
70
|
+
self._dm_relays: dict[str, tuple[list[str], int]] = {}
|
|
71
|
+
self.timeout: float = timeout
|
|
72
|
+
self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=const.INBOX_MAX)
|
|
73
|
+
self._waiting: dict[str, queue.Queue[Inbound]] = {} # thid -> Queue
|
|
74
|
+
self._seen: set[str] = set() # wrap ids, so two relays deliver once
|
|
75
|
+
self._seen_order: Deque[str] = collections.deque() # the same ids, oldest first, for the cap
|
|
76
|
+
self._partials: dict[tuple[str, str], Partial] = {} # (sender, fragment id) -> pieces of a large envelope
|
|
77
|
+
self._lock: threading.Lock = threading.Lock()
|
|
78
|
+
self._stop: threading.Event = threading.Event()
|
|
79
|
+
self._threads: list[threading.Thread] = []
|
|
80
|
+
# What this rung refused or lost, with reasons; the ladder reads it
|
|
81
|
+
# through faults, Link exports it, and `meshctl watch` reds on it.
|
|
82
|
+
self.faults: FaultLog = FaultLog('nostr', clock=self.clock, keep=const.DROPPED_KEPT)
|
|
83
|
+
# Relays that asked to be left alone for a while; see cooldown.py.
|
|
84
|
+
self.cooldown: RelayCooldown = RelayCooldown(self.clock)
|
|
85
|
+
# One entry per relay, for the operator: whether the subscription is up,
|
|
86
|
+
# when it last delivered, how often it has had to reconnect and why.
|
|
87
|
+
self.health: dict[str, RelayHealthRow] = {
|
|
88
|
+
url: {
|
|
89
|
+
'connected': False,
|
|
90
|
+
# Whether the relay ANSWERED the subscription, which is not the
|
|
91
|
+
# same as having sent it. A kind 21059 is ephemeral: a wrap a
|
|
92
|
+
# peer sends before the REQ is registered is fanned out to
|
|
93
|
+
# nobody and no relay kept a copy, so a node that believes it
|
|
94
|
+
# is listening one round trip early loses exactly the live
|
|
95
|
+
# control traffic it cannot ask for again.
|
|
96
|
+
'confirmed': False,
|
|
97
|
+
'since': None,
|
|
98
|
+
'last_event_at': None,
|
|
99
|
+
'reconnects': 0,
|
|
100
|
+
'last_error': None,
|
|
101
|
+
}
|
|
102
|
+
for url in self.relays
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unfinished(self) -> int:
|
|
107
|
+
"""Envelopes half-arrived: fragments held with pieces still missing.
|
|
108
|
+
|
|
109
|
+
A number the operator reads, so it is asked for rather than reached in
|
|
110
|
+
for. The compartment that reports it has no business in `_partials`.
|
|
111
|
+
"""
|
|
112
|
+
return len(self._partials)
|
|
113
|
+
|
|
114
|
+
def _sign_auth(self, relay_url: str, challenge: str) -> NostrEvent:
|
|
115
|
+
"""Sign a NIP-42 kind 22242 authentication event using the node's key."""
|
|
116
|
+
return wire.auth_event(self.ks.node_sk, int(self.clock()), relay_url, challenge)
|
|
117
|
+
|
|
118
|
+
def _drop(self, reason: str, wrap_id: Optional[str]) -> None:
|
|
119
|
+
self.faults.record(reason, wrap_id)
|
|
120
|
+
|
|
121
|
+
def recent_faults(self, since: Optional[int] = None) -> list[Fault]:
|
|
122
|
+
"""What this rung refused or lost, newest last, with reasons."""
|
|
123
|
+
return self.faults.recent(since)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def drop_counts(self) -> Counter[str]:
|
|
127
|
+
"""Fault family -> how many, ever."""
|
|
128
|
+
return self.faults.counts
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def dropped(self) -> Deque[tuple[str, Optional[str]]]:
|
|
132
|
+
"""(reason, wrap_id) of the most recent faults, oldest first."""
|
|
133
|
+
return collections.deque(((f['reason'], f['ident']) for f in self.faults.recent()), maxlen=self.faults.maxlen)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _fragments(jwe: Mapping[str, Any]) -> list[Any]:
|
|
138
|
+
return wire.fragments(jwe)
|