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,464 @@
|
|
|
1
|
+
"""The Peering compartment: construction, the grant writers (`issue`,
|
|
2
|
+
`decide`), the reconciler that is its tick, and the compartment contract.
|
|
3
|
+
The handshake itself is in handshake.py; the envelope in envelope.py."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Any, Callable, cast, Mapping, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from fleetmesh_core.accounting import window
|
|
10
|
+
from fleetmesh_core.identity import identity
|
|
11
|
+
from fleetmesh_core.peering import peering
|
|
12
|
+
from fleetmesh_core.services import discover
|
|
13
|
+
from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent
|
|
14
|
+
from fleetmesh_core.wire.wire_types import AmendCause, PeeringAmendBody
|
|
15
|
+
from node import channels as ch_mod
|
|
16
|
+
from node.channels import bus as eventbus_mod
|
|
17
|
+
from fleetmesh_core.identity.did import did_nostr
|
|
18
|
+
from node.storage import grants as grants_mod
|
|
19
|
+
from node import metrics as mx_mod
|
|
20
|
+
from node.compartment import stores as st
|
|
21
|
+
from node.protocols.nostr import client as relay
|
|
22
|
+
from node.config.records import PolicyConfig
|
|
23
|
+
from node.compartment import Command, Handler, Need, Outbound, Refusal, WireProtocol
|
|
24
|
+
from .envelope import CAUSES, GRANT_KIND, GrantUnpublished, LOG_KEEP, PeeringError, RECONCILE_EVERY, WireNode, open_envelope
|
|
25
|
+
from .handshake import HandshakeMixin
|
|
26
|
+
from .shape import PeeringHost
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
from .types import (
|
|
30
|
+
Issued,
|
|
31
|
+
PeeringDecision,
|
|
32
|
+
PeeringStateReport,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Peering(HandshakeMixin, WireProtocol):
|
|
37
|
+
"""The handshake and the grants, as a compartment.
|
|
38
|
+
|
|
39
|
+
Its tick is the reconciler: an amend is a notification and the addressable
|
|
40
|
+
kind 30801 is what the grant IS, so a subject reads the coordinate rather
|
|
41
|
+
than waiting to be told. Its refusals are every amend it would not act on
|
|
42
|
+
and every peering it turned away - the path that recorded nothing at all on
|
|
43
|
+
the night every amend on the mesh was lost.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name: str = 'peering'
|
|
47
|
+
reads: tuple[str, ...] = ('policy',)
|
|
48
|
+
config_cls = PolicyConfig
|
|
49
|
+
# Nothing here reaches link, trust or strain: the handshake runs on the
|
|
50
|
+
# context's keystore, descriptor, peers, transport, policy and grant
|
|
51
|
+
# store. Capacity is a back-edge (bind), not a requirement.
|
|
52
|
+
requires: tuple[str, ...] = ()
|
|
53
|
+
piuris: tuple[str, ...] = (peering.PIURI,)
|
|
54
|
+
exact_types: tuple[str, ...] = (discover.QUERIES,)
|
|
55
|
+
HANDLERS = (
|
|
56
|
+
Handler(discover.QUERIES, run='handle_discover', exact=True, doc='answer discover-features/2.0 query'),
|
|
57
|
+
Handler('propose', run='handle_propose', writes=('grants',), doc='answer peering proposal'),
|
|
58
|
+
Handler('accept', run='handle_accept', writes=('grants',), doc='take peering acceptance'),
|
|
59
|
+
Handler('amend', run='handle_amend', writes=('grants',), doc='take amended grant'),
|
|
60
|
+
Handler('terminate', run='handle_terminate', writes=('grants',), doc='tear down peering'),
|
|
61
|
+
)
|
|
62
|
+
OUTBOUND = (
|
|
63
|
+
Outbound('propose', reply=('offer', 'decline'), doc='send peering proposal to peer'),
|
|
64
|
+
Outbound('offer', reply='accept', doc='offer peering terms to proposer'),
|
|
65
|
+
Outbound('accept', doc='accept offered peering terms'),
|
|
66
|
+
Outbound('decline', doc='decline peering proposal with code and reason'),
|
|
67
|
+
Outbound('amend', doc='notify peer of amended grant'),
|
|
68
|
+
Outbound('terminate', doc='terminate active peering relationship'),
|
|
69
|
+
)
|
|
70
|
+
COMMANDS = (
|
|
71
|
+
Command(
|
|
72
|
+
key='show',
|
|
73
|
+
name='Peering',
|
|
74
|
+
description='the peerings this node holds, and when each grant was last reconciled',
|
|
75
|
+
tags=('peering', 'status'),
|
|
76
|
+
permission='peering/show',
|
|
77
|
+
run='state',
|
|
78
|
+
reply='{peers, reconciled, refused}',
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# What it keeps. The log was a list appended once per inbound peering
|
|
83
|
+
# message - including the ones it ignored or failed on, so a stranger
|
|
84
|
+
# could grow it - and never trimmed. The newest LOG_KEEP, the same as the
|
|
85
|
+
# node's own log beside it.
|
|
86
|
+
STORES = (
|
|
87
|
+
st.Store(key='log', description='what the handshake did with each message, newest last',
|
|
88
|
+
kind=st.RING, shape=tuple, bound=LOG_KEEP),
|
|
89
|
+
st.Store(key='_reconciled', description='when each peer grant coordinate was last read',
|
|
90
|
+
kind=st.TABLE, shape=int, bound=4096, keyed_by='peer', evict=st.LRU),
|
|
91
|
+
)
|
|
92
|
+
CHANNELS = (
|
|
93
|
+
ch_mod.Channel('grant_issued', 'A peering grant was issued or amended for a peer',
|
|
94
|
+
level=ch_mod.INFO, wire=eventbus_mod._wire_grant,
|
|
95
|
+
say='peering grant issued to {peer} (cause: {cause}, tier: {tier})',
|
|
96
|
+
tags=('peering', 'grant')),
|
|
97
|
+
ch_mod.Channel('peer_connected', 'A peer completed the peering handshake and connected',
|
|
98
|
+
level=ch_mod.INFO, wire=eventbus_mod._wire_peer,
|
|
99
|
+
say='peer connected: {peer} as {role} via {transport}',
|
|
100
|
+
tags=('peering', 'handshake')),
|
|
101
|
+
ch_mod.Channel('peering_declined', 'Peering proposal was declined',
|
|
102
|
+
level=ch_mod.TROUBLE, say='peering proposal declined from {peer}: {reason}',
|
|
103
|
+
tags=('peering', 'decline')),
|
|
104
|
+
ch_mod.Channel('peering_terminated', 'Peering relationship was terminated',
|
|
105
|
+
level=ch_mod.NOTE, say='peering terminated with {peer}: {reason}',
|
|
106
|
+
tags=('peering', 'terminate')),
|
|
107
|
+
)
|
|
108
|
+
METRICS = (
|
|
109
|
+
mx_mod.Metric(name='peering_grants_issued', description='Grants issued or amended.',
|
|
110
|
+
kind=mx_mod.COUNTER, counts='grant_issued', tags=('peering', 'grant')),
|
|
111
|
+
mx_mod.Metric(name='peering_peers_connected', description='Peers connected.',
|
|
112
|
+
kind=mx_mod.COUNTER, counts='peer_connected', tags=('peering', 'handshake')),
|
|
113
|
+
mx_mod.Metric(name='peering_proposals_declined', description='Peering proposals declined.',
|
|
114
|
+
kind=mx_mod.COUNTER, counts='peering_declined', tags=('peering', 'decline')),
|
|
115
|
+
mx_mod.Metric(name='peering_relationships_terminated', description='Peering relationships terminated.',
|
|
116
|
+
kind=mx_mod.COUNTER, counts='peering_terminated', tags=('peering', 'terminate')),
|
|
117
|
+
)
|
|
118
|
+
NEEDS = (
|
|
119
|
+
Need('keystore', 'ks'),
|
|
120
|
+
Need('my_descriptor', 'descriptor'),
|
|
121
|
+
Need('peers'),
|
|
122
|
+
Need('transport'),
|
|
123
|
+
Need('relay_urls', 'relays'),
|
|
124
|
+
Need('grant_store', 'stores.grants'),
|
|
125
|
+
Need('policy'),
|
|
126
|
+
Need('node_class'),
|
|
127
|
+
Need('timeout'),
|
|
128
|
+
Need('events'),
|
|
129
|
+
Need('capacity', late=True),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def bind(self, capacity: Any) -> None:
|
|
133
|
+
"""The node's own ceiling, through which a stranger is refused.
|
|
134
|
+
Capacity ticks after budget, which ticks after peering, so it is
|
|
135
|
+
bound once all three exist."""
|
|
136
|
+
self.capacity = capacity
|
|
137
|
+
|
|
138
|
+
def start(self) -> None:
|
|
139
|
+
if self.events is not None:
|
|
140
|
+
events = self.events
|
|
141
|
+
self.on_grant_issued.append(lambda *a: events.emit(eventbus_mod.TOPIC_GRANT_ISSUED, *a))
|
|
142
|
+
self.on_peered.append(lambda *a: events.emit(eventbus_mod.TOPIC_PEER_CONNECTED, *a))
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
keystore: Any,
|
|
147
|
+
my_descriptor: Any,
|
|
148
|
+
peers: Any,
|
|
149
|
+
transport: Any,
|
|
150
|
+
relay_urls: Sequence[str],
|
|
151
|
+
grant_store: Optional[grants_mod.GrantStore] = None,
|
|
152
|
+
policy: Optional[Any] = None,
|
|
153
|
+
node_class: str = 'edge',
|
|
154
|
+
clock: Optional[Callable[[], int]] = None,
|
|
155
|
+
timeout: float = 15.0,
|
|
156
|
+
capacity: Optional[Any] = None,
|
|
157
|
+
node: Optional[Any] = None,
|
|
158
|
+
publisher: Optional[Callable[[Sequence[str], Any, float], dict[str, tuple[bool, str]]]] = None,
|
|
159
|
+
host: Optional[PeeringHost] = None,
|
|
160
|
+
events: Optional[Any] = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__(node, clock)
|
|
163
|
+
self.capacity = capacity # the node's own ceiling, or None
|
|
164
|
+
self.events = events
|
|
165
|
+
# How a grant envelope reaches the relays; the control-plane publish,
|
|
166
|
+
# not the peer transport. Given so a test can make every relay refuse.
|
|
167
|
+
self.publisher: Callable[[Sequence[str], Any, float], dict[str, tuple[bool, str]]] = publisher or relay.publish_all
|
|
168
|
+
self._reconciled: st.Table[str, int] # peer -> when its grant coordinate was last read
|
|
169
|
+
self.ks: Any = keystore
|
|
170
|
+
desc = my_descriptor() if callable(my_descriptor) else my_descriptor
|
|
171
|
+
self.descriptor: Any = desc
|
|
172
|
+
self.peers: Any = peers
|
|
173
|
+
self.transport: Any = transport
|
|
174
|
+
self.relays: list[str] = list(relay_urls) # not a store: the configured relays
|
|
175
|
+
self.grants: grants_mod.GrantStore = grant_store or grants_mod.GrantStore()
|
|
176
|
+
self.timeout: float = timeout
|
|
177
|
+
# The peering state machine. fleetmesh_core decides; WireNode adds the
|
|
178
|
+
# real envelope. Anything satisfying PeeringHost may stand in (R11).
|
|
179
|
+
self.host: PeeringHost = host or WireNode(keystore.node_sk, desc, policy, node_class, clock=self.clock)
|
|
180
|
+
self.log: st.Ring[tuple[Any, ...]] # built and bound by the base (see STORES)
|
|
181
|
+
# callbacks the node bridges onto its event bus; the compartment does not know the bus
|
|
182
|
+
self.on_grant_issued: list[Callable[[str, str, str, Optional[str]], Any]] = [
|
|
183
|
+
lambda peer, cause, env_id, tier: self.say('grant_issued', peer=peer, cause=cause, envelope_id=env_id, tier=tier)
|
|
184
|
+
] # (peer, cause, envelope_id, tier)
|
|
185
|
+
def _say_peered(peer: str, role: str, transport: Optional[str] = None) -> Any:
|
|
186
|
+
return self.say('peer_connected', peer=peer, role=role, transport=transport or 'direct')
|
|
187
|
+
|
|
188
|
+
self.on_peered: list[Callable[[str, str, Optional[str]], Any]] = [_say_peered]
|
|
189
|
+
|
|
190
|
+
# ---- helpers -----------------------------------------------------------
|
|
191
|
+
def handle_discover(self, inbound: Any) -> Optional[tuple[Any, ...]]:
|
|
192
|
+
"""discover-features/2.0: a peer asks what this node speaks and is
|
|
193
|
+
answered from the descriptor, by fleetmesh_core/peering's disclose, so the
|
|
194
|
+
answer cannot name a protocol the descriptor does not. Anyone may
|
|
195
|
+
ask; the answer is what the descriptor already says in public."""
|
|
196
|
+
msg, sender = getattr(inbound, 'message', None), getattr(inbound, 'sender', None)
|
|
197
|
+
if not isinstance(msg, dict) or not isinstance(sender, str) or not discover.is_queries(msg):
|
|
198
|
+
return None
|
|
199
|
+
reply = discover.disclose(self.ks.did, did_nostr(sender), str(msg.get('id', '')),
|
|
200
|
+
self.host.disclose(discover.query_list(msg) or None))
|
|
201
|
+
try:
|
|
202
|
+
self.wire_client.send(sender, reply)
|
|
203
|
+
except Exception as e: # the asker may hold no descriptor we can address; nothing to do
|
|
204
|
+
return ('ignored', sender, 'disclose not deliverable: %s' % e)
|
|
205
|
+
return ('disclosed', sender, len(reply['body']['disclosures']))
|
|
206
|
+
|
|
207
|
+
def _flush_envelopes(self) -> dict[str, dict[str, tuple[bool, str]]]:
|
|
208
|
+
out: dict[str, dict[str, tuple[bool, str]]] = {}
|
|
209
|
+
while self.host.published:
|
|
210
|
+
ev = self.host.published.pop(0)
|
|
211
|
+
out[ev['id']] = self.publisher(self.relays, ev, self.timeout)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
def _fetch_envelope(self, envelope_id: Optional[str], issuer_pk: str) -> NostrEvent:
|
|
215
|
+
"""The grant this issuer currently publishes for the two of us.
|
|
216
|
+
|
|
217
|
+
A kind 30801 is addressable, and every grant between one pair carries
|
|
218
|
+
the same blinded `d`, so each new grant replaces the last one at that
|
|
219
|
+
coordinate. Fetching by the id an amend happens to name therefore works
|
|
220
|
+
only until the next amend: two grants issued in the same second tie on
|
|
221
|
+
created_at, NIP-01 breaks the tie on the lower id, and about half the
|
|
222
|
+
time the *newer* envelope is the one the relay drops. The subject then
|
|
223
|
+
asks for an id nothing serves.
|
|
224
|
+
|
|
225
|
+
So the coordinate is what is asked for, and the named id is a
|
|
226
|
+
preference within the answer. An amend is a notification that the grant
|
|
227
|
+
changed; the addressable event is what the grant *is*.
|
|
228
|
+
"""
|
|
229
|
+
d = identity.grant_tag(self.ks.node_sk, issuer_pk)
|
|
230
|
+
found = relay.query_all(self.relays, [{'kinds': [GRANT_KIND], 'authors': [issuer_pk],
|
|
231
|
+
'#d': [d]}], self.timeout)
|
|
232
|
+
if not found:
|
|
233
|
+
raise PeeringError('this issuer publishes no grant for us; %s was named'
|
|
234
|
+
% (envelope_id or '?')[:16])
|
|
235
|
+
exact = [e for e in found if e['id'] == envelope_id]
|
|
236
|
+
if exact:
|
|
237
|
+
return exact[0]
|
|
238
|
+
# NIP-01 supersession order: latest created_at, lower id breaks a tie.
|
|
239
|
+
return sorted(found, key=lambda e: (-e['created_at'], e['id']))[0]
|
|
240
|
+
|
|
241
|
+
def reconcile(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
|
|
242
|
+
"""Take up a grant that changed, without waiting to be told.
|
|
243
|
+
|
|
244
|
+
An amend is a notification; the addressable kind 30801 is what the grant
|
|
245
|
+
IS, and this method is that sentence made true. A subject used to learn
|
|
246
|
+
of a new grant only from a peering/1.0/amend, which travels as a stored
|
|
247
|
+
gift wrap - kind 1059, one of the four messages the specification says
|
|
248
|
+
must survive an offline recipient.
|
|
249
|
+
|
|
250
|
+
A relay is not obliged to keep one. `relay.pocketstr.com` answers a
|
|
251
|
+
kind 1059 publish with OK and true, and then neither stores it nor
|
|
252
|
+
serves it back to the recipient itself. Every amend on a four-node mesh
|
|
253
|
+
was lost that way for a night. The two ends then name different grants
|
|
254
|
+
for ever: every receipt is refused as divergent, so every window closes
|
|
255
|
+
with no claim, so every window is active silence, so every peering earns
|
|
256
|
+
an S2 that nobody did anything to deserve, and the tier falls to
|
|
257
|
+
probation where only a person can raise it.
|
|
258
|
+
|
|
259
|
+
Asking the coordinate costs one query per peering and depends on nothing
|
|
260
|
+
arriving. A protocol that must not conclude anything from an absence
|
|
261
|
+
must also not depend on a presence.
|
|
262
|
+
"""
|
|
263
|
+
now_val = now if now is not None else self.clock()
|
|
264
|
+
did: list[tuple[Any, ...]] = []
|
|
265
|
+
for peer in self.grants.peers():
|
|
266
|
+
held = self.grants.get(peer, grants_mod.RECEIVED)
|
|
267
|
+
if held is None or held['status'] != 'live':
|
|
268
|
+
continue
|
|
269
|
+
if now_val - self._reconciled.get(peer, 0) < RECONCILE_EVERY:
|
|
270
|
+
continue
|
|
271
|
+
self._reconciled[peer] = now_val
|
|
272
|
+
before = held['envelope_id']
|
|
273
|
+
try:
|
|
274
|
+
# No id is named on purpose: a named id is a preference within
|
|
275
|
+
# the answer, and what is wanted here is whatever the issuer
|
|
276
|
+
# publishes now.
|
|
277
|
+
self._take_received(None, peer)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
self._refuse('reconcile', 'could not read the grant this issuer publishes',
|
|
280
|
+
peer, {'error': '%s: %s' % (type(e).__name__, e)})
|
|
281
|
+
continue
|
|
282
|
+
now_held = self.grants.get(peer, grants_mod.RECEIVED)
|
|
283
|
+
after = now_held['envelope_id'] if now_held is not None else None
|
|
284
|
+
if after and after != before:
|
|
285
|
+
did.append(('grant reconciled', peer, before[:12], after[:12]))
|
|
286
|
+
return did
|
|
287
|
+
|
|
288
|
+
def _refuse(self, kind: str, why: str, subject: Optional[str] = None, detail: Optional[Mapping[str, Any]] = None) -> Any:
|
|
289
|
+
return self.refusals.refuse(kind, why, subject=subject, detail=detail, at=self.clock())
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def refused(self) -> list[Refusal]:
|
|
293
|
+
"""The last refusals, as the typed tuple Refusals keeps (positional
|
|
294
|
+
reads still work; a NamedTuple is a tuple)."""
|
|
295
|
+
return list(self.refusals.last(200))
|
|
296
|
+
|
|
297
|
+
def issue(
|
|
298
|
+
self,
|
|
299
|
+
peer: str,
|
|
300
|
+
payload: Mapping[str, Any],
|
|
301
|
+
cause: str,
|
|
302
|
+
reason: str,
|
|
303
|
+
effective_epoch: int,
|
|
304
|
+
now: Optional[int] = None,
|
|
305
|
+
on_clamp: Optional[Callable[[str, str, dict[str, list[Any]]], Any]] = None,
|
|
306
|
+
) -> Issued:
|
|
307
|
+
"""Write, publish and record one grant, and tell the subject why.
|
|
308
|
+
|
|
309
|
+
`effective_epoch` is the window this grant governs from. Passing the
|
|
310
|
+
window already running is what breaks a receipt chain, so a caller that
|
|
311
|
+
means "from the next boundary" should say so with `decide` instead of
|
|
312
|
+
working it out here.
|
|
313
|
+
|
|
314
|
+
Raises GrantUnpublished if no relay took the envelope, having recorded
|
|
315
|
+
nothing: a grant the subject cannot fetch is worse than no grant.
|
|
316
|
+
Raises ValueError for a cause the schema does not define.
|
|
317
|
+
"""
|
|
318
|
+
if cause not in CAUSES:
|
|
319
|
+
raise ValueError('cause %r is not one the amend schema defines: %s'
|
|
320
|
+
% (cause, ', '.join(CAUSES)))
|
|
321
|
+
now_val = self.clock() if now is None else now
|
|
322
|
+
tier = payload.get('tier')
|
|
323
|
+
if not isinstance(tier, str):
|
|
324
|
+
# ceilings() would raise a bare KeyError on None a line later; the
|
|
325
|
+
# payload is the caller's and the fault should be named as such.
|
|
326
|
+
raise ValueError('grant payload for %s names no tier' % peer[:16])
|
|
327
|
+
# Every tier has a ceiling and a grant MUST NOT set a limit above it.
|
|
328
|
+
# Only one of the three writers checked; the other two took whatever
|
|
329
|
+
# payload they were handed.
|
|
330
|
+
capped = window.within(payload.get('limits') or {}, tier)
|
|
331
|
+
if capped != (payload.get('limits') or {}):
|
|
332
|
+
over = {k: [payload['limits'][k], capped[k]] for k in capped
|
|
333
|
+
if capped[k] != payload['limits'][k]}
|
|
334
|
+
self.refusals.refuse('grant', 'carried limits above its tier ceiling', subject=peer,
|
|
335
|
+
detail={'tier': tier, 'over': over}, at=now_val)
|
|
336
|
+
if on_clamp is not None:
|
|
337
|
+
on_clamp(peer, tier, over)
|
|
338
|
+
payload = dict(payload, limits={k: (int(v) if float(v).is_integer() else v)
|
|
339
|
+
for k, v in capped.items()})
|
|
340
|
+
# The cast is the boundary between the operator's untyped payload (from
|
|
341
|
+
# app.py / budget.py) and the protocol's GrantPayload shape; the keys
|
|
342
|
+
# were checked by the schema when the grant was written, not here.
|
|
343
|
+
grant = cast(GrantPayload, dict(payload, blinding=secrets.token_bytes(32).hex()))
|
|
344
|
+
env = self.host._publish_envelope(peer, grant, now_val)
|
|
345
|
+
published = self._flush_envelopes()
|
|
346
|
+
took = {url: note for url, (ok_, note) in (published.get(env['id']) or {}).items() if ok_}
|
|
347
|
+
if self.relays and not took:
|
|
348
|
+
why = {url: note for url, (_, note) in (published.get(env['id']) or {}).items()}
|
|
349
|
+
self.refusals.refuse('grant', 'no relay would take the envelope', subject=peer,
|
|
350
|
+
detail={'envelope': env['id'], 'cause': cause, 'relays': why}, at=now_val)
|
|
351
|
+
raise GrantUnpublished(
|
|
352
|
+
'the grant for %s was not recorded: no relay took envelope %s (%s)'
|
|
353
|
+
% (peer[:16], env['id'][:12],
|
|
354
|
+
'; '.join('%s: %s' % (u, n or 'refused') for u, n in sorted(why.items())) or 'no relays'))
|
|
355
|
+
self.host.issued[peer] = grant
|
|
356
|
+
self.grants.put(peer, grants_mod.ISSUED, grant, env['id'], now_val, event=env,
|
|
357
|
+
env_created_at=env['created_at'], effective_epoch=effective_epoch)
|
|
358
|
+
self.grants.clear_pending(peer)
|
|
359
|
+
# `cause` passed the CAUSES guard above, so the Literal holds; the
|
|
360
|
+
# envelope is signed because WireNode signs, which the Protocol does
|
|
361
|
+
# not promise - a host that returned an unsigned one would fail here
|
|
362
|
+
# at the relay, not silently.
|
|
363
|
+
amend_body: PeeringAmendBody = {'reason': reason, 'cause': cast(AmendCause, cause),
|
|
364
|
+
'new_grant_envelope': env['id'], 'envelope_event': cast(NostrEvent, env)}
|
|
365
|
+
self.wire_client.send(peer, self.host._msg('amend', amend_body,
|
|
366
|
+
did_nostr(peer)), stored=True)
|
|
367
|
+
for cb in self.on_grant_issued:
|
|
368
|
+
cb(peer, cause, env['id'], tier)
|
|
369
|
+
return {'envelope': cast(NostrEvent, env), 'payload': grant, 'published': published, 'cause': cause}
|
|
370
|
+
|
|
371
|
+
def decide(
|
|
372
|
+
self,
|
|
373
|
+
peer: str,
|
|
374
|
+
payload: Mapping[str, Any],
|
|
375
|
+
reason: str,
|
|
376
|
+
severity: Optional[str],
|
|
377
|
+
decided_epoch: int,
|
|
378
|
+
) -> PeeringDecision:
|
|
379
|
+
"""Decide a grant now and let it take effect at the next boundary.
|
|
380
|
+
|
|
381
|
+
An amend inside a window makes the two ends name different grants for
|
|
382
|
+
the rest of it: the subject's receipts still cite the grant it holds,
|
|
383
|
+
the issuer has moved on, and every one is refused as naming a grant
|
|
384
|
+
that did not govern that window. The window then closes on a claim one
|
|
385
|
+
receipt short of what was measured, which is divergence, which is an S2
|
|
386
|
+
against a node that did nothing wrong.
|
|
387
|
+
|
|
388
|
+
So a decision is written down and published at the boundary. The store
|
|
389
|
+
keeps one pending grant per peer and a later decision replaces an
|
|
390
|
+
earlier one, which is how an operator's word outranks the ladder's
|
|
391
|
+
without either having to know about the other.
|
|
392
|
+
"""
|
|
393
|
+
self.grants.put_pending(peer, payload, reason, severity, decided_epoch)
|
|
394
|
+
return {'payload': payload, 'effective_from': decided_epoch + 1}
|
|
395
|
+
|
|
396
|
+
# ---- the compartment contract -------------------------------------------------
|
|
397
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
398
|
+
return self.reconcile(now)
|
|
399
|
+
|
|
400
|
+
def state(self) -> PeeringStateReport:
|
|
401
|
+
return {
|
|
402
|
+
'peers': len(self.grants.peers()),
|
|
403
|
+
'reconciled': {p[:16]: at for p, at in self._reconciled.items()},
|
|
404
|
+
'refused': self.refusals.total(),
|
|
405
|
+
# Peers that proposed from a different schema than this build. Not
|
|
406
|
+
# a fault - they peer on what we share - but a mesh on several
|
|
407
|
+
# schemas is something an operator should see, so it is here and
|
|
408
|
+
# in trouble().
|
|
409
|
+
'digest_mismatch': {p[:16]: d[:16] for p, d in self.host.digest_mismatch.items()},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def trouble(self) -> list[str]:
|
|
413
|
+
out: list[str] = []
|
|
414
|
+
recent = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='amend')
|
|
415
|
+
if recent:
|
|
416
|
+
out.append('%d amend(s) could not be acted on: %s. The two ends will name different '
|
|
417
|
+
'grants until a reconcile fixes it'
|
|
418
|
+
% (len(recent), '; '.join(sorted({r.why for r in recent}))[:120]))
|
|
419
|
+
drift = self.host.digest_mismatch
|
|
420
|
+
if drift:
|
|
421
|
+
out.append('%d peer(s) were built from another schema (%s): peering holds on the protocols '
|
|
422
|
+
'shared with each; `meshnode peers` names them' % (
|
|
423
|
+
len(drift), ', '.join(sorted({d[:8] for d in drift.values()}))))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
def _take_received(
|
|
427
|
+
self,
|
|
428
|
+
envelope_id: Optional[str],
|
|
429
|
+
issuer_pk: str,
|
|
430
|
+
envelope_event: Optional[Mapping[str, Any]] = None,
|
|
431
|
+
) -> GrantPayload:
|
|
432
|
+
"""Read the grant this issuer now publishes for us, and keep it.
|
|
433
|
+
|
|
434
|
+
Two things this must not do. It must not record the id an amend named
|
|
435
|
+
while holding a different envelope's payload, which is how the two came
|
|
436
|
+
to disagree about the tier in force. And it must not move backwards:
|
|
437
|
+
amends can arrive out of order and a grant is only superseded forwards,
|
|
438
|
+
so an envelope older than the one already held is left alone.
|
|
439
|
+
"""
|
|
440
|
+
env: Mapping[str, Any]
|
|
441
|
+
if envelope_event is not None and (envelope_id is None or envelope_event.get('id') == envelope_id):
|
|
442
|
+
env = envelope_event
|
|
443
|
+
else:
|
|
444
|
+
env = self._fetch_envelope(envelope_id, issuer_pk)
|
|
445
|
+
held = self.grants.get(issuer_pk, grants_mod.RECEIVED)
|
|
446
|
+
if held is not None and held.get('env_created_at') is not None:
|
|
447
|
+
if (env['created_at'], env['id']) < (held['env_created_at'], held['envelope_id']):
|
|
448
|
+
return held['payload']
|
|
449
|
+
if envelope_id is None and held is not None and held['status'] != 'live' and env['created_at'] <= held['updated_at']:
|
|
450
|
+
# This node zeroed the grant it held, on a terminate or a
|
|
451
|
+
# revocation, and the issuer republishes nothing when it
|
|
452
|
+
# terminates: the coordinate goes on naming the grant it ended.
|
|
453
|
+
# Reading it back on the next reconcile made a terminate undo
|
|
454
|
+
# itself - the peer that sent one while this node was down saw its
|
|
455
|
+
# own grant come back live. An envelope no newer than the zero is
|
|
456
|
+
# the one it ended; only one the issuer made afterwards is a new
|
|
457
|
+
# grant, and that is a new peering, which `propose` begins - and a
|
|
458
|
+
# handshake names its envelope, so this blind read is never it.
|
|
459
|
+
return held['payload']
|
|
460
|
+
grant = open_envelope(env, self.ks.node_sk)
|
|
461
|
+
self.grants.put(issuer_pk, grants_mod.RECEIVED, grant, env['id'], self.clock(),
|
|
462
|
+
env_created_at=env['created_at'], event=env)
|
|
463
|
+
return grant
|
|
464
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The kind 30801 grant envelope: how one is written and signed (WireNode)
|
|
2
|
+
and how the subject opens and checks one (open_envelope). Pure protocol;
|
|
3
|
+
nothing here knows about the transport, the store or the node."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Mapping, Optional, cast
|
|
10
|
+
|
|
11
|
+
from fleetmesh_core.crypto import crypto, nip44
|
|
12
|
+
from fleetmesh_core.identity import identity
|
|
13
|
+
from fleetmesh_core.peering import peering
|
|
14
|
+
from fleetmesh_core.storage import store
|
|
15
|
+
from fleetmesh_core.wire import schema_files
|
|
16
|
+
from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent
|
|
17
|
+
|
|
18
|
+
GRANT_KIND: int = 30801
|
|
19
|
+
|
|
20
|
+
# What may cause a grant to change, read from the schema rather than written
|
|
21
|
+
# out again here. Three call sites each spelled their own cause as a bare
|
|
22
|
+
# string; all three happened to be right, and nothing would have said so if one
|
|
23
|
+
# were not. The subject's rules turn on this word - only `remedy` is adverse -
|
|
24
|
+
# so a typo would file a fault against an honest issuer, or hide a real one.
|
|
25
|
+
_SCHEMA: str = schema_files.schema_path('didcomm', 'peering', 'amend.schema.json')
|
|
26
|
+
with open(_SCHEMA, encoding='utf-8') as _f:
|
|
27
|
+
CAUSES: tuple[str, ...] = tuple(json.load(_f)['properties']['body']['properties']['cause']['enum'])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PeeringError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GrantUnpublished(PeeringError):
|
|
35
|
+
"""No relay would take the envelope, so the grant was not recorded.
|
|
36
|
+
|
|
37
|
+
The envelope IS the grant: the subject fetches it by id, opens it with the
|
|
38
|
+
conversation key and checks the commitment. An issuer that records a grant
|
|
39
|
+
whose envelope reached no relay has written down a decision the subject can
|
|
40
|
+
never read, and will then refuse every receipt that names the grant the
|
|
41
|
+
subject still holds - for ever, because nothing retries and nothing says
|
|
42
|
+
why. So the store is left alone and this is raised instead, naming what
|
|
43
|
+
each relay said.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# How often a subject re-reads the grant its issuer publishes. An amend usually
|
|
48
|
+
# arrives and this changes nothing; when one does not, this is the whole of what
|
|
49
|
+
# keeps the two ends naming the same grant.
|
|
50
|
+
RECONCILE_EVERY: int = 300
|
|
51
|
+
# How much of the handshake log is kept: the node's own log keeps as many.
|
|
52
|
+
LOG_KEEP: int = 500
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WireNode(peering.Node):
|
|
57
|
+
"""The reference node with one method made real: the envelope it publishes."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
sk_hex: str,
|
|
62
|
+
descriptor: Any,
|
|
63
|
+
policy: Optional[Any] = None,
|
|
64
|
+
node_class: str = 'edge',
|
|
65
|
+
clock: Optional[Callable[[], int]] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(sk_hex, descriptor, policy, node_class)
|
|
68
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
69
|
+
self.published: list[NostrEvent] = [] # kind 30801 events awaiting publication
|
|
70
|
+
self._last_envelope_at: dict[str, int] = {} # subject -> created_at of the last one written
|
|
71
|
+
# The serve thread answers a propose with an offer while the tick
|
|
72
|
+
# thread's own propose comes back with an accept, and both write a
|
|
73
|
+
# grant for the same subject. Unlocked, both read the same `last` and
|
|
74
|
+
# stamped the same second, which is exactly the tie the guard below
|
|
75
|
+
# exists to prevent; on the rig the relay kept one and the issuer the other.
|
|
76
|
+
self._envelope_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def _publish_envelope(self, subject: str, grant: GrantPayload, now: Optional[int]) -> NostrEvent:
|
|
79
|
+
"""Write and sign the kind 30801 for one grant.
|
|
80
|
+
|
|
81
|
+
Every grant to one subject carries the same blinded `d`, so each new one
|
|
82
|
+
replaces the last at that addressable coordinate - and NIP-01 breaks a
|
|
83
|
+
created_at tie on the *lower* id, so two grants written in the same
|
|
84
|
+
second leave the relay holding whichever id sorts first, which is the
|
|
85
|
+
older one about half the time. The issuer then believes it granted
|
|
86
|
+
something the subject can never read. A grant is only superseded
|
|
87
|
+
forwards, so the timestamp moves forwards too.
|
|
88
|
+
"""
|
|
89
|
+
with self._envelope_lock:
|
|
90
|
+
now_val = now or self.clock()
|
|
91
|
+
last = self._last_envelope_at.get(subject)
|
|
92
|
+
if last is not None and now_val <= last:
|
|
93
|
+
now_val = last + 1
|
|
94
|
+
self._last_envelope_at[subject] = now_val
|
|
95
|
+
return self._write_envelope(subject, grant, now_val)
|
|
96
|
+
|
|
97
|
+
def _write_envelope(self, subject: str, grant: GrantPayload, now_val: int) -> NostrEvent:
|
|
98
|
+
d = identity.grant_tag(self.sk, subject)
|
|
99
|
+
canon = crypto.canonical(grant)
|
|
100
|
+
commit = crypto.commit(canon, grant['blinding'])
|
|
101
|
+
window_s = grant['window']
|
|
102
|
+
tags = [['d', d], ['commit', commit],
|
|
103
|
+
['scheme', peering.CONSTANTS['commitment']['scheme'], 'nip44/2'],
|
|
104
|
+
['window', str(window_s)], ['epoch', str(now_val // window_s)],
|
|
105
|
+
['expiration', str(now_val + window_s * 24)]]
|
|
106
|
+
conv = nip44.conversation_key(self.sk, subject)
|
|
107
|
+
ev = store.make_signed_event(self.sk, now_val, GRANT_KIND, tags, nip44.encrypt(canon.decode(), conv))
|
|
108
|
+
self.envelopes[subject] = ev
|
|
109
|
+
self.published.append(ev)
|
|
110
|
+
return ev
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def open_envelope(envelope: NostrEvent | Mapping[str, Any], my_sk: str) -> GrantPayload:
|
|
114
|
+
"""The grant inside a kind 30801 addressed to me, verified against its tags.
|
|
115
|
+
|
|
116
|
+
Three things must hold: the d tag is the blinded pair identifier I derive
|
|
117
|
+
myself; the ciphertext opens under the conversation key; and the commit
|
|
118
|
+
tag opens to the plaintext under the blinding the plaintext carries.
|
|
119
|
+
"""
|
|
120
|
+
if envelope.get('kind') != GRANT_KIND:
|
|
121
|
+
raise PeeringError('not a kind 30801')
|
|
122
|
+
issuer = envelope['pubkey']
|
|
123
|
+
tags = {t[0]: t for t in envelope.get('tags', []) if t and isinstance(t, (list, tuple))}
|
|
124
|
+
d_tag = tags.get('d')
|
|
125
|
+
d_val = d_tag[1] if d_tag and len(d_tag) > 1 else None
|
|
126
|
+
if not d_val or d_val != identity.grant_tag(my_sk, issuer):
|
|
127
|
+
raise PeeringError('the d tag is not the pair identifier I derive with this issuer')
|
|
128
|
+
try:
|
|
129
|
+
plain = nip44.decrypt(envelope['content'], nip44.conversation_key(my_sk, issuer))
|
|
130
|
+
grant = json.loads(plain)
|
|
131
|
+
except (nip44.Nip44Error, ValueError) as e:
|
|
132
|
+
raise PeeringError('the envelope does not open: %s' % e)
|
|
133
|
+
if grant.get('subject') != crypto.pubkey(my_sk):
|
|
134
|
+
raise PeeringError('the grant names another subject')
|
|
135
|
+
commit_tag = tags.get('commit')
|
|
136
|
+
commit_val = commit_tag[1] if commit_tag and len(commit_tag) > 1 else None
|
|
137
|
+
if not commit_val or not crypto.open_commit(commit_val, crypto.canonical(grant), grant.get('blinding', '')):
|
|
138
|
+
raise PeeringError('the commitment does not open to the grant')
|
|
139
|
+
# json.loads gives a dict; the three checks above are what makes it a
|
|
140
|
+
# grant addressed to me. Its keys are node/validate.py's business.
|
|
141
|
+
return cast(GrantPayload, grant)
|
|
142
|
+
|