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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. 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
@@ -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()