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,44 @@
|
|
|
1
|
+
"""A sliding one-minute rate limiter, shared by both relay bridges.
|
|
2
|
+
|
|
3
|
+
The node's bridge compartment and the strfry plugin (`fleetmesh_strfry`) each
|
|
4
|
+
carried this class, line for line (audit 2026-10-15, D4). It lives here, in a
|
|
5
|
+
module that imports nothing of the node but its default, so the plugin - which
|
|
6
|
+
already runs against the meshnode checkout - can take it without importing the
|
|
7
|
+
bridge compartment.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import collections
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Deque, Optional
|
|
15
|
+
|
|
16
|
+
from node.config.compartments import DEFAULT_RATE_LIMIT_PER_MIN
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimiter:
|
|
20
|
+
"""Sliding-window rate limiter tracking event timestamps within a 60-second interval."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MIN) -> None:
|
|
23
|
+
self.limit: int = max(1, int(limit_per_minute))
|
|
24
|
+
self._timestamps: Deque[float] = collections.deque()
|
|
25
|
+
self._lock: threading.Lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def allow(self, now: Optional[float] = None) -> bool:
|
|
28
|
+
t = now if now is not None else time.time()
|
|
29
|
+
window_start = t - 60.0
|
|
30
|
+
with self._lock:
|
|
31
|
+
while self._timestamps and self._timestamps[0] < window_start:
|
|
32
|
+
self._timestamps.popleft()
|
|
33
|
+
if len(self._timestamps) >= self.limit:
|
|
34
|
+
return False
|
|
35
|
+
self._timestamps.append(t)
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def count(self, now: Optional[float] = None) -> int:
|
|
39
|
+
t = now if now is not None else time.time()
|
|
40
|
+
window_start = t - 60.0
|
|
41
|
+
with self._lock:
|
|
42
|
+
while self._timestamps and self._timestamps[0] < window_start:
|
|
43
|
+
self._timestamps.popleft()
|
|
44
|
+
return len(self._timestamps)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Types and records for the Bridge compartment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IngressStats(TypedDict):
|
|
8
|
+
enabled: bool
|
|
9
|
+
stored: int
|
|
10
|
+
duplicates: int
|
|
11
|
+
p2p_forward_failures: int
|
|
12
|
+
refused: int
|
|
13
|
+
last_event_at: Optional[int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EgressStats(TypedDict):
|
|
17
|
+
enabled: bool
|
|
18
|
+
sent: int
|
|
19
|
+
failed: int
|
|
20
|
+
pending: int
|
|
21
|
+
last_event_at: Optional[int]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RelayWorkerStats(TypedDict):
|
|
25
|
+
url: str
|
|
26
|
+
connected: bool
|
|
27
|
+
last_connected_at: Optional[int]
|
|
28
|
+
ingress_count: int
|
|
29
|
+
egress_count: int
|
|
30
|
+
errors_count: int
|
|
31
|
+
last_error: Optional[str]
|
|
32
|
+
handler_errors: int
|
|
33
|
+
last_handler_error: Optional[str]
|
|
34
|
+
last_event_at: Optional[int]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BridgeStateReport(TypedDict):
|
|
38
|
+
enabled: bool
|
|
39
|
+
relays_configured: int
|
|
40
|
+
relays_connected: int
|
|
41
|
+
ingress: IngressStats
|
|
42
|
+
egress: EgressStats
|
|
43
|
+
relays: dict[str, RelayWorkerStats]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Finding a node on the same link, with no relay and nothing configured.
|
|
2
|
+
|
|
3
|
+
One-module compartment: everything is in compartment.py.
|
|
4
|
+
"""
|
|
5
|
+
from .compartment import Lan
|
|
6
|
+
from .types import LanPeerRecord, LanStateReport
|
|
7
|
+
|
|
8
|
+
__all__ = ['Lan', 'LanPeerRecord', 'LanStateReport']
|
|
9
|
+
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Finding a node on the same link, with no relay and nothing configured.
|
|
2
|
+
|
|
3
|
+
Bridge 5, and the only one that needs nothing outside the room. The
|
|
4
|
+
specification's four bootstrap paths all start somewhere else: a relay holds
|
|
5
|
+
the descriptor, gossipsub needs a peer to reach the topic, and an operator
|
|
6
|
+
typing an address has to know one. `fleetmesh_core/tests/test_unplug.py` passes with
|
|
7
|
+
every relay gone, but only because the peers were already in the config - two
|
|
8
|
+
nodes that had never met could not have found each other, which is the part of
|
|
9
|
+
the unplug claim nothing was demonstrating.
|
|
10
|
+
|
|
11
|
+
A node advertises `_fleetmesh._tcp.local.` over multicast DNS, carrying its
|
|
12
|
+
pubkey, the protocol digest it speaks, and the port of its own relay face. It
|
|
13
|
+
browses for the same, and for each answer it does the thing that makes the
|
|
14
|
+
hint worth having: it asks that face for the advertiser's kind 11801.
|
|
15
|
+
|
|
16
|
+
That is the whole trick. Every node already serves a relay face, a descriptor
|
|
17
|
+
is an ordinary event on it, and `descriptor.resolve` takes any relay URL. So
|
|
18
|
+
the peer's own face is the relay, the descriptor it serves is signed by the
|
|
19
|
+
key the advert claimed, and `peers.learn` refuses it if the two disagree. A
|
|
20
|
+
stranger on the link may advertise any pubkey it likes and gets nowhere: it
|
|
21
|
+
cannot produce a descriptor signed by a key it does not hold.
|
|
22
|
+
|
|
23
|
+
**The digest does not filter.** Since 0.3 two nodes built from different
|
|
24
|
+
schemas peer on the protocols they share (fleetmesh_core/identity.shared_protocols), so an
|
|
25
|
+
advert with a different digest is a candidate like any other. The advert still
|
|
26
|
+
carries the digest: it is provenance, kept per node in `meshnode lan` so an
|
|
27
|
+
operator can see a link drifting onto several schemas without dialling anyone.
|
|
28
|
+
|
|
29
|
+
Discovery ends at a validated descriptor. Peering is the scheduler's business
|
|
30
|
+
and an operator's decision, as it is for every other bridge.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from fleetmesh_core.wire import spelling # noqa: E402
|
|
35
|
+
|
|
36
|
+
import ipaddress
|
|
37
|
+
import socket
|
|
38
|
+
import threading
|
|
39
|
+
from typing import Any, Callable, Mapping, Optional
|
|
40
|
+
|
|
41
|
+
from node.protocols.nostr import descriptor as descriptor_mod
|
|
42
|
+
from node.compartment import stores as st
|
|
43
|
+
from node.channels import Channel, TROUBLE
|
|
44
|
+
from node.metrics import COUNTER, Metric
|
|
45
|
+
from node.transport import mdns
|
|
46
|
+
from node.compartment import Command, Compartment, Every, Need
|
|
47
|
+
from node.config import LanConfig
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
from .types import LanPeerRecord, LanStateReport
|
|
51
|
+
|
|
52
|
+
SERVICE: str = '_fleetmesh._tcp.local'
|
|
53
|
+
# The TXT keys. Short because a TXT record is small and every advertisement
|
|
54
|
+
# carries all of them; `pk` and `d` are what a reader acts on, `c` is a hint.
|
|
55
|
+
KEY_PUBKEY: str = 'pk'
|
|
56
|
+
KEY_DIGEST: str = 'd'
|
|
57
|
+
KEY_CLASS: str = 'c'
|
|
58
|
+
# How much of the digest travels. Sixteen hex is eight bytes of it, which is
|
|
59
|
+
# far past any accident, and a full one would be most of the record.
|
|
60
|
+
DIGEST_CHARS: int = 16
|
|
61
|
+
# A node is re-announced this often even when nothing has changed, because a
|
|
62
|
+
# record expires and a listener that joined late has heard nothing.
|
|
63
|
+
ANNOUNCE_EVERY: int = 60
|
|
64
|
+
# What one browse may bring back, and how long a peer stays known after its
|
|
65
|
+
# last advert. A link is not a grant, so the bound is here.
|
|
66
|
+
SEEN_MAX: int = 128
|
|
67
|
+
FORGET_AFTER: int = 600
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def instance_name(pubkey: str) -> str:
|
|
71
|
+
"""The DNS-SD instance: this node's key, short, under the service type.
|
|
72
|
+
|
|
73
|
+
Sixteen hex is enough to be unique on a link and short enough to read in
|
|
74
|
+
`dns-sd -B`. The full key travels in the TXT, which is what is acted on.
|
|
75
|
+
"""
|
|
76
|
+
return '%s.%s' % (pubkey[:16], SERVICE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _not_dialable(ip: str, src: Optional[str]) -> Optional[str]:
|
|
80
|
+
"""Why this node will not open a socket to the address an advert names,
|
|
81
|
+
or None if it will. Two rules, in the order they are cheapest."""
|
|
82
|
+
try:
|
|
83
|
+
target = ipaddress.ip_address(ip)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return 'the advertised address is not an IP address'
|
|
86
|
+
if not (target.is_private or target.is_link_local or target.is_loopback):
|
|
87
|
+
return 'the advertised address is not on a local network'
|
|
88
|
+
if src is not None:
|
|
89
|
+
try:
|
|
90
|
+
if ipaddress.ip_address(src) != target:
|
|
91
|
+
return 'the advertised address is not the one that sent the packet'
|
|
92
|
+
except ValueError:
|
|
93
|
+
return 'the advertised address is not the one that sent the packet'
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Lan(Compartment):
|
|
98
|
+
|
|
99
|
+
name: str = 'lan'
|
|
100
|
+
reads: tuple[str, ...] = ('lan',)
|
|
101
|
+
config_cls = LanConfig
|
|
102
|
+
NEEDS = (
|
|
103
|
+
Need('keystore', 'ks'),
|
|
104
|
+
Need('peers', 'peers'),
|
|
105
|
+
Need('face', 'face'),
|
|
106
|
+
Need('node_class', 'node_class'),
|
|
107
|
+
Need('timeout', 'timeout'),
|
|
108
|
+
)
|
|
109
|
+
EVERY = (Every(seconds=ANNOUNCE_EVERY, run='_announce'),)
|
|
110
|
+
COMMANDS = (
|
|
111
|
+
Command(
|
|
112
|
+
key='show',
|
|
113
|
+
name='Lan',
|
|
114
|
+
description='what this node advertises on its link and what it has heard there',
|
|
115
|
+
tags=('lan', 'status'),
|
|
116
|
+
permission='lan/show',
|
|
117
|
+
run='state',
|
|
118
|
+
reply='{enabled, service, advertising, address, port, seen, learned, refused}',
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
STORES = (
|
|
122
|
+
st.Store(key='_seen', description='LAN peers seen via broadcast beacon',
|
|
123
|
+
kind=st.TABLE, shape=dict, bound=1024, keyed_by='peer', evict=st.LRU),
|
|
124
|
+
st.Store(key='_resolved', description='when peer descriptor was last fetched over LAN',
|
|
125
|
+
kind=st.TABLE, shape=int, bound=1024, keyed_by='peer', evict=st.LRU),
|
|
126
|
+
)
|
|
127
|
+
CHANNELS = (
|
|
128
|
+
Channel('socket_error', 'Cannot join mDNS multicast group', level=TROUBLE,
|
|
129
|
+
say='cannot join the mDNS group ({error}); the other bridges are unaffected'),
|
|
130
|
+
Channel('advertising', 'Local node mDNS service advertisement started',
|
|
131
|
+
say='advertising {instance} on port {port}'),
|
|
132
|
+
Channel('send_error', 'Multicast link cannot carry advertisement', level=TROUBLE,
|
|
133
|
+
say='the link will not carry an advertisement ({error}); nothing else is affected'),
|
|
134
|
+
)
|
|
135
|
+
METRICS = (
|
|
136
|
+
Metric(name='lan_advertisements', description='Times mDNS advertisement was started.',
|
|
137
|
+
kind=COUNTER, counts='advertising', tags=('lan', 'mdns')),
|
|
138
|
+
Metric(name='lan_socket_errors', description='Times mDNS socket join failed.',
|
|
139
|
+
kind=COUNTER, counts='socket_error', tags=('lan', 'mdns', 'error')),
|
|
140
|
+
Metric(name='lan_send_errors', description='Times mDNS packet send failed.',
|
|
141
|
+
kind=COUNTER, counts='send_error', tags=('lan', 'mdns', 'error')),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
keystore: Any,
|
|
147
|
+
peers: Any,
|
|
148
|
+
face: Any,
|
|
149
|
+
digest: Optional[str] = None,
|
|
150
|
+
node_class: str = 'edge',
|
|
151
|
+
clock: Optional[Callable[[], int]] = None,
|
|
152
|
+
timeout: float = 5.0,
|
|
153
|
+
node: Optional[Any] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
super().__init__(node, clock)
|
|
156
|
+
self.ks: Any = keystore
|
|
157
|
+
self.peers: Any = peers
|
|
158
|
+
self.face: Any = face
|
|
159
|
+
self.digest: str = digest or getattr(node, 'digest', None) or descriptor_mod.DIGEST
|
|
160
|
+
self.node_class: str = node_class
|
|
161
|
+
self.timeout: float = timeout
|
|
162
|
+
self.sock: Optional[socket.socket] = None
|
|
163
|
+
self.address: Optional[str] = None
|
|
164
|
+
self._announced_at: int = 0
|
|
165
|
+
self._carried: Optional[bool] = None # did the last packet leave the machine
|
|
166
|
+
self._seen: st.Table[str, LanPeerRecord] # pubkey -> {host, port, at}
|
|
167
|
+
self._resolved: st.Table[str, int] # pubkey -> when its descriptor was last fetched
|
|
168
|
+
self.learned: int = 0
|
|
169
|
+
self._up: Optional[bool] = None
|
|
170
|
+
self._lock: threading.Lock = threading.Lock()
|
|
171
|
+
|
|
172
|
+
# ---- configuration --------------------------------------------------------
|
|
173
|
+
def _cfg(self) -> Mapping[str, Any]:
|
|
174
|
+
c = self.config('lan')
|
|
175
|
+
return c if isinstance(c, dict) else {}
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def enabled(self) -> bool:
|
|
179
|
+
return bool(self.settings().enabled)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def face_port(self) -> int:
|
|
183
|
+
try:
|
|
184
|
+
return int(getattr(self.face.server, 'port', 0) or 0)
|
|
185
|
+
except (AttributeError, TypeError, ValueError):
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
# ---- what this node says about itself -------------------------------------
|
|
189
|
+
def txt(self) -> dict[str, str]:
|
|
190
|
+
return {KEY_PUBKEY: self.ks.node_pk,
|
|
191
|
+
KEY_DIGEST: self.digest[:DIGEST_CHARS],
|
|
192
|
+
KEY_CLASS: self.node_class}
|
|
193
|
+
|
|
194
|
+
def records(self, ttl: int = mdns.TTL) -> list[mdns.Record]:
|
|
195
|
+
"""The four records one advertisement is: what exists, where it is,
|
|
196
|
+
what it says about itself, and the address behind the name."""
|
|
197
|
+
inst = instance_name(self.ks.node_pk)
|
|
198
|
+
host = '%s.local' % self.ks.node_pk[:16]
|
|
199
|
+
out = [mdns.Record(SERVICE, mdns.PTR, inst, ttl),
|
|
200
|
+
mdns.Record(inst, mdns.SRV, (0, 0, self.face_port, host), ttl),
|
|
201
|
+
mdns.Record(inst, mdns.TXT, self.txt(), ttl)]
|
|
202
|
+
if self.address:
|
|
203
|
+
out.append(mdns.Record(host, mdns.A, self.address, ttl))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
# ---- lifecycle ------------------------------------------------------------
|
|
207
|
+
def start(self) -> None:
|
|
208
|
+
if not self.enabled:
|
|
209
|
+
return
|
|
210
|
+
self._open()
|
|
211
|
+
|
|
212
|
+
def _open(self) -> bool:
|
|
213
|
+
if self.sock is not None:
|
|
214
|
+
return True
|
|
215
|
+
try:
|
|
216
|
+
self.sock = mdns.open_socket()
|
|
217
|
+
self.address = mdns.local_address()
|
|
218
|
+
except OSError as e:
|
|
219
|
+
if self._up is not False:
|
|
220
|
+
self.say('socket_error', error=e)
|
|
221
|
+
self.refusals.refuse('socket', 'cannot join the group', detail={'error': str(e)}, at=self.clock())
|
|
222
|
+
self._up, self.sock = False, None
|
|
223
|
+
return False
|
|
224
|
+
if self._up is not True:
|
|
225
|
+
self.say('advertising', instance=instance_name(self.ks.node_pk), port=self.face_port)
|
|
226
|
+
self._up = True
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def stop(self) -> Any:
|
|
230
|
+
"""Say goodbye before going, so a listener forgets this node at once
|
|
231
|
+
rather than holding a dead address until the record expires."""
|
|
232
|
+
if self.sock is None:
|
|
233
|
+
return None
|
|
234
|
+
self._say(mdns.build(answers=self.records(ttl=0)), 'goodbye')
|
|
235
|
+
try:
|
|
236
|
+
self.sock.close()
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
self.sock = None
|
|
240
|
+
return [('lan', 'said goodbye')]
|
|
241
|
+
|
|
242
|
+
# ---- the tick -------------------------------------------------------------
|
|
243
|
+
_tick_runs_every = True
|
|
244
|
+
|
|
245
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
246
|
+
if not self.enabled:
|
|
247
|
+
if self.sock is not None:
|
|
248
|
+
self.stop()
|
|
249
|
+
return []
|
|
250
|
+
if not self._open():
|
|
251
|
+
return []
|
|
252
|
+
did: list[tuple[Any, ...]] = []
|
|
253
|
+
did += self._serve(now)
|
|
254
|
+
did += self.run_every(now)
|
|
255
|
+
self._forget(now)
|
|
256
|
+
did += self._resolve(now)
|
|
257
|
+
return did
|
|
258
|
+
|
|
259
|
+
def _say(self, packet: bytes, what: str) -> bool:
|
|
260
|
+
"""One packet on the group. A link that will not carry it is a
|
|
261
|
+
refusal, not a traceback.
|
|
262
|
+
|
|
263
|
+
A joined socket is not a link that forwards: a host with no route for
|
|
264
|
+
224.0.0.0/4, or a network that filters multicast, refuses the send
|
|
265
|
+
itself with ENETUNREACH or EPERM. Raised out of the tick, that would
|
|
266
|
+
cost this compartment the rest of its own work - forgetting expired
|
|
267
|
+
adverts and fetching the descriptors of peers it has already heard -
|
|
268
|
+
which is the shape of the bug where one peer that could not be
|
|
269
|
+
addressed ended the tick for every peer behind it.
|
|
270
|
+
"""
|
|
271
|
+
assert self.sock is not None
|
|
272
|
+
try:
|
|
273
|
+
mdns.send(self.sock, packet)
|
|
274
|
+
except (OSError, mdns.MdnsError) as e:
|
|
275
|
+
if self._carried is not False:
|
|
276
|
+
self.say('send_error', error=e)
|
|
277
|
+
self._carried = False
|
|
278
|
+
self.refusals.refuse('send', 'the link would not carry it',
|
|
279
|
+
detail={'what': what, 'error': '%s: %s' % (type(e).__name__, e)},
|
|
280
|
+
at=self.clock())
|
|
281
|
+
return False
|
|
282
|
+
self._carried = True
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
def _announce(self, now: Optional[int] = None) -> None:
|
|
286
|
+
"""Say what this node is, unsolicited, and ask who else is here."""
|
|
287
|
+
if not self.enabled or not self._open() or self.sock is None:
|
|
288
|
+
return
|
|
289
|
+
now = now if now is not None else self.clock()
|
|
290
|
+
# Stamped before the sends rather than after them, which is the
|
|
291
|
+
# opposite of the handover's order and for the opposite reason: an
|
|
292
|
+
# advertisement repeats every ANNOUNCE_EVERY seconds by design, so one
|
|
293
|
+
# that did not go is picked up by the next one. Stamping after a
|
|
294
|
+
# failure would re-announce on every tick instead, which is a packet
|
|
295
|
+
# storm at a link that has already said no.
|
|
296
|
+
self._announced_at = now
|
|
297
|
+
if self.face_port:
|
|
298
|
+
self._say(mdns.build(answers=self.records()), 'announcement')
|
|
299
|
+
self._say(mdns.build(questions=[(SERVICE, mdns.PTR)]), 'browse')
|
|
300
|
+
|
|
301
|
+
def _serve(self, now: int) -> list[tuple[Any, ...]]:
|
|
302
|
+
"""Answer what was asked, and note what was announced."""
|
|
303
|
+
assert self.sock is not None
|
|
304
|
+
did: list[tuple[Any, ...]] = []
|
|
305
|
+
for msg, _src in mdns.receive(self.sock):
|
|
306
|
+
if any(n.rstrip('.') == SERVICE and t in (mdns.PTR, 255) for n, t in msg.questions):
|
|
307
|
+
if self.face_port:
|
|
308
|
+
self._say(mdns.build(answers=self.records()), 'answer')
|
|
309
|
+
if msg.is_response:
|
|
310
|
+
did += self._heard(msg, now, src=_src)
|
|
311
|
+
return did
|
|
312
|
+
|
|
313
|
+
def _heard(self, msg: mdns.Message, now: int, src: Optional[str] = None) -> list[tuple[Any, ...]]:
|
|
314
|
+
"""Assemble SRV and TXT for one instance into a candidate.
|
|
315
|
+
|
|
316
|
+
src is the address the packet came from. An advert names the host
|
|
317
|
+
this node will open a WebSocket to, and until it was checked the
|
|
318
|
+
name could be anything: a multicast packet on the link could point
|
|
319
|
+
every listening node at an arbitrary address and port - a service on
|
|
320
|
+
the operator's own network that trusts its callers, or an address
|
|
321
|
+
on the internet - with this node making the connection. So the A
|
|
322
|
+
record must be the address that sent the packet (a node advertises
|
|
323
|
+
itself, from itself), and it must be one a LAN can hold: private,
|
|
324
|
+
link-local or loopback. The tests hand messages in without a source
|
|
325
|
+
and are held to the second rule alone.
|
|
326
|
+
"""
|
|
327
|
+
srv: dict[str, tuple[int, str]] = {}
|
|
328
|
+
txt: dict[str, dict[str, str]] = {}
|
|
329
|
+
addr: dict[str, str] = {}
|
|
330
|
+
for rec in msg.answers:
|
|
331
|
+
if rec.rtype == mdns.SRV and isinstance(rec.data, tuple):
|
|
332
|
+
srv[rec.name.rstrip('.')] = (rec.data[2], rec.data[3].rstrip('.'))
|
|
333
|
+
elif rec.rtype == mdns.TXT and isinstance(rec.data, dict):
|
|
334
|
+
txt[rec.name.rstrip('.')] = rec.data
|
|
335
|
+
elif rec.rtype == mdns.A and isinstance(rec.data, str):
|
|
336
|
+
addr[rec.name.rstrip('.')] = rec.data
|
|
337
|
+
did: list[tuple[Any, ...]] = []
|
|
338
|
+
for inst, entries in txt.items():
|
|
339
|
+
pk = (entries.get(KEY_PUBKEY) or '').lower()
|
|
340
|
+
if not spelling.is_hex(pk, 64):
|
|
341
|
+
self.refusals.refuse('advert', 'no usable pubkey', subject=inst[:32], at=now)
|
|
342
|
+
continue
|
|
343
|
+
if pk == self.ks.node_pk:
|
|
344
|
+
continue # our own advert, looped back
|
|
345
|
+
theirs = entries.get(KEY_DIGEST) or ''
|
|
346
|
+
# A different digest is not a refusal (see the module note); it is
|
|
347
|
+
# recorded with the node below so the operator can see it.
|
|
348
|
+
port, host = srv.get(inst, (0, ''))
|
|
349
|
+
if not port:
|
|
350
|
+
continue # TXT without its SRV; the rest may follow
|
|
351
|
+
ip = addr.get(host) or self._seen.get(pk, {}).get('host')
|
|
352
|
+
if not ip:
|
|
353
|
+
continue # no address yet; the A record may follow
|
|
354
|
+
why = _not_dialable(ip, src)
|
|
355
|
+
if why is not None:
|
|
356
|
+
self.refusals.refuse('advert', why, subject=pk, detail={'host': ip, 'src': src}, at=now)
|
|
357
|
+
continue
|
|
358
|
+
with self._lock:
|
|
359
|
+
fresh = pk not in self._seen
|
|
360
|
+
if len(self._seen) >= SEEN_MAX and fresh:
|
|
361
|
+
self.refusals.refuse('advert', 'more nodes on this link than it holds',
|
|
362
|
+
detail={'max': SEEN_MAX}, at=now)
|
|
363
|
+
continue
|
|
364
|
+
self._seen[pk] = {'host': ip, 'port': port, 'at': now, 'digest': theirs[:DIGEST_CHARS],
|
|
365
|
+
'same_digest': theirs == self.digest[:DIGEST_CHARS]}
|
|
366
|
+
if fresh:
|
|
367
|
+
did.append(('lan', pk[:16], 'seen at %s:%d' % (ip, port)))
|
|
368
|
+
return did
|
|
369
|
+
|
|
370
|
+
def _resolve(self, now: int) -> list[tuple[Any, ...]]:
|
|
371
|
+
"""Ask each new neighbour's own face for the descriptor it claims.
|
|
372
|
+
|
|
373
|
+
This is where an advert stops being a claim. The face serves a kind
|
|
374
|
+
11801 signed by the node key; `peers.learn` runs it through
|
|
375
|
+
`fleetmesh_core.identity.validate_descriptor` and a BIP-340 check, so a spoofed
|
|
376
|
+
advert yields either nothing or somebody else's valid descriptor, and
|
|
377
|
+
the pubkey comparison below refuses that too.
|
|
378
|
+
"""
|
|
379
|
+
did: list[tuple[Any, ...]] = []
|
|
380
|
+
with self._lock:
|
|
381
|
+
todo: list[tuple[str, LanPeerRecord]] = [(pk, v) for pk, v in self._seen.items()
|
|
382
|
+
if now - self._resolved.get(pk, 0) >= FORGET_AFTER]
|
|
383
|
+
for pk, where in todo[:8]:
|
|
384
|
+
self._resolved[pk] = now
|
|
385
|
+
url = 'ws://%s:%d' % (where['host'], int(where['port']))
|
|
386
|
+
try:
|
|
387
|
+
desc = descriptor_mod.resolve(pk, [url], now=now, timeout=self.timeout)
|
|
388
|
+
except Exception as e:
|
|
389
|
+
self.refusals.refuse('resolve', 'the face did not answer', subject=pk,
|
|
390
|
+
detail={'url': url, 'error': '%s: %s' % (type(e).__name__, e)}, at=now)
|
|
391
|
+
continue
|
|
392
|
+
if desc is None:
|
|
393
|
+
self.refusals.refuse('resolve', 'no descriptor on the advertised face',
|
|
394
|
+
subject=pk, detail={'url': url}, at=now)
|
|
395
|
+
continue
|
|
396
|
+
if desc.get('pubkey') != pk:
|
|
397
|
+
self.refusals.refuse('resolve', 'the face served another node\'s descriptor',
|
|
398
|
+
subject=pk, detail={'served': str(desc.get('pubkey'))[:16]}, at=now)
|
|
399
|
+
continue
|
|
400
|
+
if self.peers.learn(desc):
|
|
401
|
+
self.learned += 1
|
|
402
|
+
did.append(('lan', pk[:16], 'descriptor learned from its own face'))
|
|
403
|
+
return did
|
|
404
|
+
|
|
405
|
+
def _forget(self, now: int) -> None:
|
|
406
|
+
with self._lock:
|
|
407
|
+
for pk in [p for p, v in self._seen.items() if now - v['at'] > FORGET_AFTER]:
|
|
408
|
+
del self._seen[pk]
|
|
409
|
+
self._resolved.pop(pk, None)
|
|
410
|
+
|
|
411
|
+
# ---- being watched --------------------------------------------------------
|
|
412
|
+
def state(self) -> LanStateReport:
|
|
413
|
+
with self._lock:
|
|
414
|
+
seen: dict[str, LanPeerRecord] = dict(self._seen)
|
|
415
|
+
return {
|
|
416
|
+
'enabled': self.enabled,
|
|
417
|
+
'service': SERVICE,
|
|
418
|
+
# Not "the socket is open": a joined socket that cannot send is the
|
|
419
|
+
# case this compartment exists to survive, and reporting it as
|
|
420
|
+
# advertising was the lie that made the swallowed OSError invisible.
|
|
421
|
+
'advertising': self.sock is not None and bool(self.face_port) and self._carried is not False,
|
|
422
|
+
'address': self.address,
|
|
423
|
+
'port': self.face_port,
|
|
424
|
+
'digest': self.digest[:DIGEST_CHARS],
|
|
425
|
+
'seen': {pk[:16]: v for pk, v in seen.items()},
|
|
426
|
+
'learned': self.learned,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
def trouble(self) -> list[str]:
|
|
430
|
+
if not self.enabled:
|
|
431
|
+
return []
|
|
432
|
+
out: list[str] = []
|
|
433
|
+
if self._up is False:
|
|
434
|
+
out.append('the mDNS group could not be joined; this node is not discoverable on its link')
|
|
435
|
+
elif self._carried is False:
|
|
436
|
+
out.append('the link will not carry a multicast packet, so nothing is advertised and '
|
|
437
|
+
'nothing here is heard; the other bridges are unaffected')
|
|
438
|
+
elif self.sock is not None and not self.face_port:
|
|
439
|
+
out.append('no relay face port, so nothing is advertised: a neighbour would have '
|
|
440
|
+
'nowhere to ask for the descriptor')
|
|
441
|
+
with self._lock:
|
|
442
|
+
drift = sorted(pk[:16] for pk, v in self._seen.items() if not v.get('same_digest', True))
|
|
443
|
+
if drift:
|
|
444
|
+
out.append('%d node(s) on this link were built from another schema and peer on what is '
|
|
445
|
+
'shared: %s' % (len(drift), ', '.join(drift)))
|
|
446
|
+
return out
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Types and records for the LAN compartment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LanPeerRecord(TypedDict, total=False):
|
|
8
|
+
"""Metadata recorded for a peer seen on the LAN link."""
|
|
9
|
+
host: str
|
|
10
|
+
port: int
|
|
11
|
+
at: int
|
|
12
|
+
digest: str
|
|
13
|
+
same_digest: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LanStateReport(TypedDict, total=False):
|
|
17
|
+
"""The report Lan.state() gives of itself."""
|
|
18
|
+
enabled: bool
|
|
19
|
+
service: str
|
|
20
|
+
advertising: bool
|
|
21
|
+
address: Optional[str]
|
|
22
|
+
port: int
|
|
23
|
+
digest: str
|
|
24
|
+
seen: dict[str, LanPeerRecord]
|
|
25
|
+
learned: int
|