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,22 @@
|
|
|
1
|
+
"""Types and report structures for the Iroh transport rung."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IrohHealthReport(TypedDict, total=False):
|
|
8
|
+
scheme: str
|
|
9
|
+
alpn: str
|
|
10
|
+
mode: str
|
|
11
|
+
connected: bool
|
|
12
|
+
listening: bool
|
|
13
|
+
endpoint: str
|
|
14
|
+
ticket: str
|
|
15
|
+
node_id: str
|
|
16
|
+
streams_in: int
|
|
17
|
+
streams_out: int
|
|
18
|
+
bytes_in: int
|
|
19
|
+
bytes_out: int
|
|
20
|
+
last_error: Optional[str]
|
|
21
|
+
active_sidecar: bool
|
|
22
|
+
quic_engine: Any
|
node/transport/ladder.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Per-rung circuit breaker and the rung base class for the transport ladder.
|
|
2
|
+
|
|
3
|
+
The ladder itself - which rung is tried first for a peer, and in what order
|
|
4
|
+
the rest follow - lives in router.py (TransportRouter); the order is
|
|
5
|
+
fips-native, iroh, libp2p, nostr and is not decided here. This module holds
|
|
6
|
+
the pieces every rung shares: a CircuitBreaker that stops the router from
|
|
7
|
+
hanging on a target that keeps failing, a FaultLog so that what a rung
|
|
8
|
+
dropped and why is readable rather than only countable. The rung contract
|
|
9
|
+
itself (RungProtocol, Rung) is in rung.py. Standard library only.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import collections
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Counter, Deque, Dict, Iterable, Optional, TypedDict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Fault(TypedDict):
|
|
20
|
+
"""One thing a rung refused or lost, kept so an operator can read it."""
|
|
21
|
+
at: int # the node clock when it happened
|
|
22
|
+
rung: str # 'nostr', 'fips-native', 'iroh', 'libp2p', or 'router'
|
|
23
|
+
reason: str # 'publish refused: wss://... invalid: ...'; the prefix before ':' is the family
|
|
24
|
+
ident: Optional[str] # the wrap, stream or message id it concerned, when there was one
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FaultLog:
|
|
28
|
+
"""The most recent faults of one rung and a count per family, ever.
|
|
29
|
+
|
|
30
|
+
A count alone said '0 faults' while every wrap was being refused; the
|
|
31
|
+
reasons are what tell an operator the relay said 'ephemeral event
|
|
32
|
+
expired'. Thread-safe; readers get copies.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, rung: str, clock: Any = None, keep: int = 200) -> None:
|
|
36
|
+
self.rung: str = rung
|
|
37
|
+
self.clock: Any = clock or (lambda: int(time.time()))
|
|
38
|
+
self._recent: Deque[Fault] = collections.deque(maxlen=keep)
|
|
39
|
+
self.counts: Counter[str] = collections.Counter()
|
|
40
|
+
self._lock: threading.Lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
def record(self, reason: str, ident: Optional[str] = None) -> Fault:
|
|
43
|
+
f = Fault(at=int(self.clock()), rung=self.rung, reason=reason, ident=ident)
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._recent.append(f)
|
|
46
|
+
self.counts[reason.split(':', 1)[0].strip()] += 1
|
|
47
|
+
return f
|
|
48
|
+
|
|
49
|
+
def recent(self, since: Optional[int] = None, family: Optional[str] = None) -> list[Fault]:
|
|
50
|
+
"""Newest last. `since` keeps faults at or after that clock reading;
|
|
51
|
+
`family` keeps the reason prefix ('publish refused')."""
|
|
52
|
+
with self._lock:
|
|
53
|
+
rows = list(self._recent)
|
|
54
|
+
return [f for f in rows
|
|
55
|
+
if (since is None or f['at'] >= since)
|
|
56
|
+
and (family is None or f['reason'].split(':', 1)[0].strip() == family)]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def maxlen(self) -> Optional[int]:
|
|
60
|
+
return self._recent.maxlen
|
|
61
|
+
|
|
62
|
+
def __len__(self) -> int:
|
|
63
|
+
return len(self._recent)
|
|
64
|
+
|
|
65
|
+
def __iter__(self) -> Iterable[Fault]:
|
|
66
|
+
with self._lock:
|
|
67
|
+
return iter(list(self._recent))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def merge_faults(logs: Iterable[FaultLog], since: Optional[int] = None) -> list[Fault]:
|
|
71
|
+
"""Faults across rungs in time order, for the ladder's aggregate view."""
|
|
72
|
+
out: list[Fault] = []
|
|
73
|
+
for lg in logs:
|
|
74
|
+
out.extend(lg.recent(since))
|
|
75
|
+
out.sort(key=lambda f: f['at'])
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CircuitBreaker:
|
|
80
|
+
"""Per-target circuit breaker to avoid hanging on repeatedly failing transports."""
|
|
81
|
+
|
|
82
|
+
STATE_CLOSED: str = 'closed'
|
|
83
|
+
STATE_OPEN: str = 'open'
|
|
84
|
+
STATE_HALF_OPEN: str = 'half_open'
|
|
85
|
+
|
|
86
|
+
def __init__(self, failure_threshold: int = 3, recovery_timeout: float = 30.0) -> None:
|
|
87
|
+
self.failure_threshold: int = failure_threshold
|
|
88
|
+
self.recovery_timeout: float = recovery_timeout
|
|
89
|
+
self._lock: threading.RLock = threading.RLock()
|
|
90
|
+
self._failures: collections.Counter[str] = collections.Counter()
|
|
91
|
+
self._last_failure: Dict[str, float] = {}
|
|
92
|
+
self._states: Dict[str, str] = {}
|
|
93
|
+
|
|
94
|
+
def allow_attempt(self, target: str, now: Optional[float] = None) -> bool:
|
|
95
|
+
"""Return True if an attempt to reach target is permitted."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
t_now = time.time() if now is None else now
|
|
98
|
+
state = self._states.get(target, self.STATE_CLOSED)
|
|
99
|
+
if state == self.STATE_CLOSED:
|
|
100
|
+
return True
|
|
101
|
+
last = self._last_failure.get(target, 0.0)
|
|
102
|
+
if t_now - last >= self.recovery_timeout:
|
|
103
|
+
self._states[target] = self.STATE_HALF_OPEN
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def record_success(self, target: str) -> None:
|
|
108
|
+
"""Record successful transmission, resetting circuit to CLOSED."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._failures[target] = 0
|
|
111
|
+
self._states[target] = self.STATE_CLOSED
|
|
112
|
+
|
|
113
|
+
def record_failure(self, target: str, now: Optional[float] = None) -> None:
|
|
114
|
+
"""Record transmission failure, potentially tripping circuit to OPEN."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
t_now = time.time() if now is None else now
|
|
117
|
+
self._last_failure[target] = t_now
|
|
118
|
+
self._failures[target] += 1
|
|
119
|
+
if self._failures[target] >= self.failure_threshold:
|
|
120
|
+
self._states[target] = self.STATE_OPEN
|
|
121
|
+
|
|
122
|
+
def state(self, target: str) -> str:
|
|
123
|
+
"""Current state for target."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
return self._states.get(target, self.STATE_CLOSED)
|
|
126
|
+
|
|
127
|
+
def failures(self, target: str) -> int:
|
|
128
|
+
"""Return current consecutive failure count for target."""
|
|
129
|
+
with self._lock:
|
|
130
|
+
return self._failures[target]
|
|
131
|
+
|
|
132
|
+
def reset(self, target: Optional[str] = None) -> None:
|
|
133
|
+
"""Reset circuit breaker for a target or all targets."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
if target is None:
|
|
136
|
+
self._failures.clear()
|
|
137
|
+
self._last_failure.clear()
|
|
138
|
+
self._states.clear()
|
|
139
|
+
else:
|
|
140
|
+
self._failures.pop(target, None)
|
|
141
|
+
self._last_failure.pop(target, None)
|
|
142
|
+
self._states.pop(target, None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ['CircuitBreaker', 'Fault', 'FaultLog', 'merge_faults']
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Newline-delimited JSON over a stream socket.
|
|
2
|
+
|
|
3
|
+
A reply on a stream socket arrives in as many pieces as the kernel and the
|
|
4
|
+
writer choose. One `recv(65536)` sees whatever happens to be in the buffer:
|
|
5
|
+
half a line when the writer paused, or a line and a half when it did not.
|
|
6
|
+
Readers of a line protocol loop until the delimiter, with a cap so a peer
|
|
7
|
+
that never sends one cannot grow this process without bound.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import socket
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_CAP: int = 1 << 20
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_line(sock: socket.socket, cap: int = DEFAULT_CAP) -> bytes:
|
|
19
|
+
"""Read from `sock` until a newline and return the line without it.
|
|
20
|
+
|
|
21
|
+
Bytes after the newline in the same recv are discarded: this is for a
|
|
22
|
+
one-shot request/reply exchange, not for a long-lived multiplexed stream.
|
|
23
|
+
Raises ValueError if the peer closes before a newline (EOF) or sends
|
|
24
|
+
`cap` bytes without one. Socket timeouts propagate as the caller set
|
|
25
|
+
them (socket.timeout / TimeoutError).
|
|
26
|
+
"""
|
|
27
|
+
if cap <= 0:
|
|
28
|
+
raise ValueError('cap must be positive')
|
|
29
|
+
buf = bytearray()
|
|
30
|
+
while True:
|
|
31
|
+
chunk = sock.recv(min(65536, cap - len(buf) + 1))
|
|
32
|
+
if not chunk:
|
|
33
|
+
raise ValueError('line: connection closed before a newline (%d bytes buffered)' % len(buf))
|
|
34
|
+
buf.extend(chunk)
|
|
35
|
+
nl = buf.find(b'\n')
|
|
36
|
+
if nl >= 0:
|
|
37
|
+
return bytes(buf[:nl])
|
|
38
|
+
if len(buf) > cap:
|
|
39
|
+
raise ValueError('line: %d bytes without a newline exceeds the %d-byte cap' % (len(buf), cap))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_json_line(sock: socket.socket, cap: int = DEFAULT_CAP) -> Any:
|
|
43
|
+
"""Read one newline-terminated JSON value from `sock`.
|
|
44
|
+
|
|
45
|
+
Raises ValueError on EOF before the newline, on a line past `cap` bytes,
|
|
46
|
+
and on a line that is not JSON (json.JSONDecodeError is a ValueError).
|
|
47
|
+
"""
|
|
48
|
+
line = read_line(sock, cap=cap)
|
|
49
|
+
return json.loads(line.decode('utf-8'))
|
node/transport/mdns.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Enough of RFC 6762 and RFC 6763 for a node to find another on its own link.
|
|
2
|
+
|
|
3
|
+
Standard library only, like `ws.py` and for the same reason: this is the one
|
|
4
|
+
discovery path that must work when there is no relay, no DNS and no internet,
|
|
5
|
+
so it cannot be the path that needs a package index to install.
|
|
6
|
+
|
|
7
|
+
What is here is the DNS-SD subset a service advertisement uses and nothing
|
|
8
|
+
else: PTR, SRV, TXT and A, a query for one service type, and the records that
|
|
9
|
+
answer it. Name compression is decoded because other responders emit it, and
|
|
10
|
+
never written, because a service advertisement is small and a pointer saves
|
|
11
|
+
nothing worth the code.
|
|
12
|
+
|
|
13
|
+
The wire is the ordinary DNS message format on multicast. What makes it mDNS
|
|
14
|
+
rather than DNS is where it goes - 224.0.0.251:5353, TTL 255, joined by every
|
|
15
|
+
participant - and that an answer may arrive unsolicited, which is how a node
|
|
16
|
+
that has just started announces itself to a link that never asked.
|
|
17
|
+
|
|
18
|
+
None of this authenticates anything and it is not supposed to. A TXT record is
|
|
19
|
+
a routing hint: anyone on the link can claim any key. What proves a node is the
|
|
20
|
+
descriptor fetched afterwards from the address the hint gave, which is signed.
|
|
21
|
+
`lan.py` is where that happens.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import socket
|
|
26
|
+
import struct
|
|
27
|
+
from typing import Any, Iterator, Optional
|
|
28
|
+
|
|
29
|
+
GROUP: str = '224.0.0.251'
|
|
30
|
+
PORT: int = 5353
|
|
31
|
+
# RFC 6762 section 11: every mDNS packet is sent with a TTL of 255, and a
|
|
32
|
+
# receiver may check it. It is how the protocol keeps itself on one link.
|
|
33
|
+
HOP: int = 255
|
|
34
|
+
# Record types. The four a service advertisement needs.
|
|
35
|
+
A: int = 1
|
|
36
|
+
PTR: int = 12
|
|
37
|
+
TXT: int = 16
|
|
38
|
+
SRV: int = 33
|
|
39
|
+
IN: int = 1
|
|
40
|
+
# The cache-flush bit, the top bit of the class field in an answer. It tells a
|
|
41
|
+
# receiver that this record replaces what it holds rather than joining it,
|
|
42
|
+
# which is what a node that has changed address needs.
|
|
43
|
+
FLUSH: int = 0x8000
|
|
44
|
+
# What a record is good for. Short enough that a node which vanishes without a
|
|
45
|
+
# goodbye is forgotten in minutes, long enough not to re-query constantly.
|
|
46
|
+
TTL: int = 120
|
|
47
|
+
MAX_PACKET: int = 9000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MdnsError(ValueError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------- names ----
|
|
55
|
+
def encode_name(name: str) -> bytes:
|
|
56
|
+
"""A dotted name as length-prefixed labels, terminated by a zero byte."""
|
|
57
|
+
out = bytearray()
|
|
58
|
+
for label in name.rstrip('.').split('.'):
|
|
59
|
+
raw = label.encode('utf-8')
|
|
60
|
+
if not raw or len(raw) > 63:
|
|
61
|
+
raise MdnsError('a label is 1 to 63 bytes: %r' % label[:20])
|
|
62
|
+
out.append(len(raw))
|
|
63
|
+
out += raw
|
|
64
|
+
out.append(0)
|
|
65
|
+
if len(out) > 255:
|
|
66
|
+
raise MdnsError('a name is at most 255 bytes')
|
|
67
|
+
return bytes(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def decode_name(buf: bytes, off: int) -> tuple[str, int]:
|
|
71
|
+
"""(name, offset after it). Follows compression pointers, and refuses a
|
|
72
|
+
loop: a pointer must always point strictly backwards, so a packet that
|
|
73
|
+
points forward or to itself is malformed rather than an infinite read."""
|
|
74
|
+
labels: list[str] = []
|
|
75
|
+
here, jumped, hops = off, None, 0
|
|
76
|
+
while True:
|
|
77
|
+
if here >= len(buf):
|
|
78
|
+
raise MdnsError('name runs past the packet')
|
|
79
|
+
n = buf[here]
|
|
80
|
+
if n & 0xC0 == 0xC0:
|
|
81
|
+
if here + 1 >= len(buf):
|
|
82
|
+
raise MdnsError('a compression pointer runs past the packet')
|
|
83
|
+
target = ((n & 0x3F) << 8) | buf[here + 1]
|
|
84
|
+
if target >= here:
|
|
85
|
+
raise MdnsError('a compression pointer must point backwards')
|
|
86
|
+
hops += 1
|
|
87
|
+
if hops > 64:
|
|
88
|
+
raise MdnsError('too many compression pointers')
|
|
89
|
+
if jumped is None:
|
|
90
|
+
jumped = here + 2
|
|
91
|
+
here = target
|
|
92
|
+
continue
|
|
93
|
+
here += 1
|
|
94
|
+
if n == 0:
|
|
95
|
+
break
|
|
96
|
+
if here + n > len(buf):
|
|
97
|
+
raise MdnsError('a label runs past the packet')
|
|
98
|
+
labels.append(buf[here:here + n].decode('utf-8', 'replace'))
|
|
99
|
+
here += n
|
|
100
|
+
return '.'.join(labels), (jumped if jumped is not None else here)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------- records ----
|
|
104
|
+
class Record:
|
|
105
|
+
__slots__ = ('name', 'rtype', 'ttl', 'data')
|
|
106
|
+
|
|
107
|
+
def __init__(self, name: str, rtype: int, data: Any, ttl: int = TTL) -> None:
|
|
108
|
+
self.name, self.rtype, self.ttl, self.data = name, rtype, ttl, data
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return 'Record(%r, %d, %r)' % (self.name, self.rtype, self.data)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def encode_txt(pairs: dict[str, str]) -> bytes:
|
|
115
|
+
"""DNS-SD TXT: one length-prefixed `key=value` string per entry, sorted so
|
|
116
|
+
the same map always encodes to the same bytes."""
|
|
117
|
+
out = bytearray()
|
|
118
|
+
for k in sorted(pairs):
|
|
119
|
+
item = ('%s=%s' % (k, pairs[k])).encode('utf-8')
|
|
120
|
+
if len(item) > 255:
|
|
121
|
+
raise MdnsError('a TXT entry is at most 255 bytes: %s' % k)
|
|
122
|
+
out.append(len(item))
|
|
123
|
+
out += item
|
|
124
|
+
return bytes(out) or b'\x00'
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def decode_txt(raw: bytes) -> dict[str, str]:
|
|
128
|
+
out: dict[str, str] = {}
|
|
129
|
+
i = 0
|
|
130
|
+
while i < len(raw):
|
|
131
|
+
n = raw[i]
|
|
132
|
+
i += 1
|
|
133
|
+
if n == 0:
|
|
134
|
+
continue
|
|
135
|
+
item = raw[i:i + n].decode('utf-8', 'replace')
|
|
136
|
+
i += n
|
|
137
|
+
key, _, value = item.partition('=')
|
|
138
|
+
if key:
|
|
139
|
+
out.setdefault(key, value)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rdata(rec: Record) -> bytes:
|
|
144
|
+
if rec.rtype == PTR:
|
|
145
|
+
return encode_name(rec.data)
|
|
146
|
+
if rec.rtype == TXT:
|
|
147
|
+
return encode_txt(rec.data) if isinstance(rec.data, dict) else bytes(rec.data)
|
|
148
|
+
if rec.rtype == SRV:
|
|
149
|
+
prio, weight, port, target = rec.data
|
|
150
|
+
return struct.pack('!HHH', prio, weight, port) + encode_name(target)
|
|
151
|
+
if rec.rtype == A:
|
|
152
|
+
return socket.inet_aton(rec.data)
|
|
153
|
+
raise MdnsError('this speaks PTR, TXT, SRV and A, not type %d' % rec.rtype)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build(
|
|
157
|
+
questions: list[tuple[str, int]] | None = None,
|
|
158
|
+
answers: list[Record] | None = None,
|
|
159
|
+
additionals: list[Record] | None = None,
|
|
160
|
+
flush: bool = True,
|
|
161
|
+
) -> bytes:
|
|
162
|
+
"""One mDNS message. A message with answers is a response (QR set); one
|
|
163
|
+
with only questions is a query. The id is zero: mDNS matches on content,
|
|
164
|
+
never on a transaction id, because an answer may be unsolicited."""
|
|
165
|
+
qs, ans, adds = questions or [], answers or [], additionals or []
|
|
166
|
+
flags = 0x8400 if ans else 0x0000 # QR + AA when answering
|
|
167
|
+
out = bytearray(struct.pack('!HHHHHH', 0, flags, len(qs), len(ans), 0, len(adds)))
|
|
168
|
+
for name, qtype in qs:
|
|
169
|
+
out += encode_name(name) + struct.pack('!HH', qtype, IN)
|
|
170
|
+
for rec in ans + adds:
|
|
171
|
+
body = _rdata(rec)
|
|
172
|
+
klass = IN | (FLUSH if flush and rec.rtype != PTR else 0)
|
|
173
|
+
out += encode_name(rec.name) + struct.pack('!HHIH', rec.rtype, klass, rec.ttl, len(body)) + body
|
|
174
|
+
if len(out) > MAX_PACKET:
|
|
175
|
+
raise MdnsError('packet of %d bytes is larger than this sends' % len(out))
|
|
176
|
+
return bytes(out)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Message:
|
|
180
|
+
__slots__ = ('questions', 'answers', 'is_response')
|
|
181
|
+
|
|
182
|
+
def __init__(self, questions: list[tuple[str, int]], answers: list[Record], is_response: bool) -> None:
|
|
183
|
+
self.questions, self.answers, self.is_response = questions, answers, is_response
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse(buf: bytes) -> Message:
|
|
187
|
+
"""A message, with every record this speaks decoded and the rest skipped.
|
|
188
|
+
|
|
189
|
+
A packet from the link is whatever anyone put on it, so every length is
|
|
190
|
+
checked against the buffer before it is used and an unknown type is walked
|
|
191
|
+
over by its own length rather than guessed at.
|
|
192
|
+
"""
|
|
193
|
+
if len(buf) < 12:
|
|
194
|
+
raise MdnsError('a DNS message is at least 12 bytes')
|
|
195
|
+
_, flags, qd, an, ns, ar = struct.unpack('!HHHHHH', buf[:12])
|
|
196
|
+
off = 12
|
|
197
|
+
questions: list[tuple[str, int]] = []
|
|
198
|
+
for _ in range(qd):
|
|
199
|
+
name, off = decode_name(buf, off)
|
|
200
|
+
if off + 4 > len(buf):
|
|
201
|
+
raise MdnsError('a question runs past the packet')
|
|
202
|
+
qtype, _qclass = struct.unpack('!HH', buf[off:off + 4])
|
|
203
|
+
off += 4
|
|
204
|
+
questions.append((name, qtype))
|
|
205
|
+
answers: list[Record] = []
|
|
206
|
+
for _ in range(an + ns + ar):
|
|
207
|
+
if off >= len(buf):
|
|
208
|
+
break # a truncated section is what it is
|
|
209
|
+
name, off = decode_name(buf, off)
|
|
210
|
+
if off + 10 > len(buf):
|
|
211
|
+
raise MdnsError('a record header runs past the packet')
|
|
212
|
+
rtype, _klass, ttl, rdlen = struct.unpack('!HHIH', buf[off:off + 10])
|
|
213
|
+
off += 10
|
|
214
|
+
if off + rdlen > len(buf):
|
|
215
|
+
raise MdnsError('rdata runs past the packet')
|
|
216
|
+
body, end = buf[off:off + rdlen], off + rdlen
|
|
217
|
+
try:
|
|
218
|
+
if rtype == PTR:
|
|
219
|
+
answers.append(Record(name, PTR, decode_name(buf, off)[0], ttl))
|
|
220
|
+
elif rtype == TXT:
|
|
221
|
+
answers.append(Record(name, TXT, decode_txt(body), ttl))
|
|
222
|
+
elif rtype == SRV and rdlen >= 7:
|
|
223
|
+
prio, weight, port = struct.unpack('!HHH', body[:6])
|
|
224
|
+
answers.append(Record(name, SRV, (prio, weight, port, decode_name(buf, off + 6)[0]), ttl))
|
|
225
|
+
elif rtype == A and rdlen == 4:
|
|
226
|
+
answers.append(Record(name, A, socket.inet_ntoa(body), ttl))
|
|
227
|
+
except MdnsError:
|
|
228
|
+
pass # one unreadable record is not a bad packet
|
|
229
|
+
off = end
|
|
230
|
+
return Message(questions, answers, bool(flags & 0x8000))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------- socket ----
|
|
234
|
+
def open_socket(bind_group: bool = True) -> socket.socket:
|
|
235
|
+
"""A socket on the mDNS group, shareable with every other responder here.
|
|
236
|
+
|
|
237
|
+
SO_REUSEPORT as well as SO_REUSEADDR, because two nodes on one machine is
|
|
238
|
+
the arrangement this project already runs - lon1 has carried two since
|
|
239
|
+
2026-09-04 - and without it the second one cannot bind and the pair is
|
|
240
|
+
invisible to each other and to everyone else.
|
|
241
|
+
"""
|
|
242
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
243
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
244
|
+
if hasattr(socket, 'SO_REUSEPORT'):
|
|
245
|
+
try:
|
|
246
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
247
|
+
except OSError:
|
|
248
|
+
pass # some kernels decline it; one node still works
|
|
249
|
+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, HOP)
|
|
250
|
+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
|
|
251
|
+
s.bind(('', PORT if bind_group else 0))
|
|
252
|
+
if bind_group:
|
|
253
|
+
s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
|
254
|
+
struct.pack('4s4s', socket.inet_aton(GROUP), socket.inet_aton('0.0.0.0')))
|
|
255
|
+
s.settimeout(0.5)
|
|
256
|
+
return s
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def send(sock: socket.socket, packet: bytes) -> None:
|
|
260
|
+
"""Put one packet on the group. Raises OSError if the link refuses it.
|
|
261
|
+
|
|
262
|
+
It used to swallow that, on the grounds that no route to the group is not
|
|
263
|
+
this node's fault. True, and the wrong layer to decide it: a joined socket
|
|
264
|
+
is not a link that forwards, and a host with no route for 224.0.0.0/4 or a
|
|
265
|
+
network that filters multicast - which is what a cloud private network
|
|
266
|
+
usually is - refuses the send itself. Swallowed here, such a node
|
|
267
|
+
advertised nothing, said nothing about it, and reported itself as
|
|
268
|
+
advertising. This layer says what happened; `Lan._say` decides what it
|
|
269
|
+
means and puts it where an operator reads it.
|
|
270
|
+
"""
|
|
271
|
+
sock.sendto(packet, (GROUP, PORT))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def receive(sock: socket.socket) -> Iterator[tuple[Message, str]]:
|
|
275
|
+
"""Every message readable right now, with the address it came from."""
|
|
276
|
+
while True:
|
|
277
|
+
try:
|
|
278
|
+
data, addr = sock.recvfrom(MAX_PACKET)
|
|
279
|
+
except (socket.timeout, BlockingIOError):
|
|
280
|
+
return
|
|
281
|
+
except OSError:
|
|
282
|
+
return
|
|
283
|
+
try:
|
|
284
|
+
yield parse(data), addr[0]
|
|
285
|
+
except MdnsError:
|
|
286
|
+
continue # a malformed packet is a packet, not a fault
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def local_address() -> Optional[str]:
|
|
290
|
+
"""This machine's address on the interface that reaches the link.
|
|
291
|
+
|
|
292
|
+
Asked of a disconnected UDP socket, which chooses a route without sending
|
|
293
|
+
anything: there is nothing to reach on a dead network, and this still
|
|
294
|
+
answers because the kernel already knows which interface it would use.
|
|
295
|
+
"""
|
|
296
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
297
|
+
try:
|
|
298
|
+
s.connect((GROUP, PORT))
|
|
299
|
+
return s.getsockname()[0]
|
|
300
|
+
except OSError:
|
|
301
|
+
return None
|
|
302
|
+
finally:
|
|
303
|
+
s.close()
|