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,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
@@ -0,0 +1,7 @@
1
+ """The relay links this node holds open, as a compartment.
2
+
3
+ One-module compartment: everything is in compartment.py.
4
+ """
5
+ from .compartment import Link
6
+
7
+ __all__ = ['Link']