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,283 @@
1
+ """netfetch: the one HTTP fetch helper every outbound request in node/ goes through.
2
+
3
+ Why this exists. The node used to call urllib.request.urlopen() directly from
4
+ half a dozen places -- compute.resolve_blob (URL chosen by the job requester),
5
+ the Blossom /mirror handler (URL chosen by whoever holds an auth event),
6
+ BlossomClient (peer servers), the Cashu mint client and the watcher. Each site
7
+ re-invented the same three mistakes:
8
+
9
+ 1. no scheme check, so ``file:///etc/passwd`` or ``ftp://`` were accepted by
10
+ urllib's default opener;
11
+ 2. no address check, so a requester could point the node at
12
+ ``http://127.0.0.1:<port>/`` (its own control face) or at the cloud
13
+ metadata endpoint ``169.254.169.254`` -- classic SSRF;
14
+ 3. ``resp.read()`` with no cap, so a hostile server could hand back gigabytes
15
+ and exhaust memory.
16
+
17
+ This module fixes them once. ``fetch()`` allows only http/https, resolves the
18
+ host and refuses any address that is loopback, link-local, private (RFC 1918
19
+ and ULA), multicast, reserved or unspecified -- including IPv4-mapped IPv6
20
+ forms -- unless the caller explicitly opts in with ``allow_private=True``
21
+ (appropriate only for operator-configured URLs such as a local mint or a LAN
22
+ CI API, never for requester-supplied ones). Redirects are re-checked against
23
+ the same policy, so a public host cannot bounce us into the LAN. Bodies are
24
+ read to ``max_bytes + 1`` and anything beyond raises ``FetchTooLarge``; a
25
+ Content-Length larger than the cap is refused before a single body byte is
26
+ read.
27
+
28
+ Note on naming: this module is deliberately NOT called ``http.py``. Scripts
29
+ run from inside ``node/`` (``python3 node/demo.py``) put ``node/`` first on
30
+ ``sys.path``, and a ``node/http.py`` would shadow the stdlib ``http`` package
31
+ that ``http.server``, ``urllib`` and ``ws`` depend on.
32
+
33
+ Note on DNS rebinding: the address check resolves the hostname once and then
34
+ lets urllib resolve it again for the actual connection. A malicious resolver
35
+ that answers differently on the second lookup could still reach a private
36
+ address. Closing that hole properly means connecting to the vetted IP and
37
+ sending the hostname in Host/SNI ourselves; that is more surgery than a
38
+ stdlib opener allows cleanly, so it is recorded here rather than pretended
39
+ away. Requester-supplied URLs are additionally gated by SHA-256 verification
40
+ at every call site, which limits what a rebinding attacker can learn.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import http.client
45
+ import ipaddress
46
+ import socket
47
+ import urllib.error
48
+ import urllib.parse
49
+ import urllib.request
50
+ from typing import Any, Mapping, Optional, Sequence
51
+
52
+ DEFAULT_TIMEOUT: float = 10.0
53
+ USER_AGENT: str = 'fleetmesh-node/1.0'
54
+ MAX_REDIRECTS: int = 5
55
+
56
+
57
+ class FetchError(Exception):
58
+ """Base class: anything that stops fetch() returning a body."""
59
+
60
+
61
+ class FetchRefused(FetchError):
62
+ """The URL was refused by policy (scheme, private address, redirect target)."""
63
+
64
+
65
+ class FetchTooLarge(FetchError):
66
+ """The body (declared or actual) exceeds max_bytes."""
67
+
68
+
69
+ class FetchHTTPError(FetchError):
70
+ """The server answered with a non-2xx status. ``code`` and ``body`` are kept
71
+ so callers that care (404 means 'not there', mint error JSON) can inspect
72
+ them without the raw urllib exception leaking through."""
73
+
74
+ def __init__(self, code: int, reason: str, body: bytes = b'', headers: Optional[Mapping[str, str]] = None) -> None:
75
+ super().__init__('HTTP %d: %s' % (code, reason))
76
+ self.code = code
77
+ self.reason = reason
78
+ self.body = body
79
+ self.headers = dict(headers or {})
80
+
81
+
82
+ class FetchNetworkError(FetchError):
83
+ """DNS, connect, TLS, timeout or protocol failure."""
84
+
85
+
86
+ def _is_forbidden_address(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
87
+ """True for any address a requester must not be able to make the node talk to.
88
+
89
+ IPv4-mapped IPv6 (``::ffff:127.0.0.1``) is unwrapped first so the IPv4
90
+ policy applies; otherwise ``ipaddress`` classifies it only by the IPv6
91
+ rules and, e.g., ``::ffff:10.0.0.1`` slips through as 'global'.
92
+ """
93
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
94
+ ip = ip.ipv4_mapped
95
+ return (
96
+ ip.is_loopback
97
+ or ip.is_link_local
98
+ or ip.is_private
99
+ or ip.is_multicast
100
+ or ip.is_unspecified
101
+ or ip.is_reserved
102
+ or not ip.is_global
103
+ )
104
+
105
+
106
+ def _private_exempt(host: str, addrs: Sequence[str], allow_private: Any) -> bool:
107
+ """Is this destination exempt from the address policy?
108
+
109
+ ``allow_private`` is either a bool (True: anything goes -- operator URLs
110
+ only) or a collection of hostnames / IP literals. The collection form
111
+ exempts exactly those destinations and nothing else, so an operator who
112
+ runs a mint on 127.0.0.1 does not thereby open the whole LAN, and a
113
+ redirect from the exempt host to some other private address is still
114
+ refused.
115
+ """
116
+ if allow_private is True:
117
+ return True
118
+ if not allow_private:
119
+ return False
120
+ exempt = {str(h).lower() for h in allow_private}
121
+ if host.lower() in exempt:
122
+ return True
123
+ return bool(addrs) and all(a in exempt for a in addrs)
124
+
125
+
126
+ def check_url(url: str, *, allow_private: Any = False) -> urllib.parse.SplitResult:
127
+ """Validate scheme and destination address; return the split URL.
128
+
129
+ Raises FetchRefused for anything that fails policy. Resolution failures
130
+ are FetchNetworkError: a name that does not resolve is not an attack,
131
+ just an unreachable host, and callers treat it like a connection error.
132
+ """
133
+ try:
134
+ parts = urllib.parse.urlsplit(str(url))
135
+ except ValueError as e:
136
+ raise FetchRefused('malformed URL: %s' % e)
137
+ if parts.scheme not in ('http', 'https'):
138
+ raise FetchRefused('scheme %r not allowed (http/https only)' % parts.scheme)
139
+ host = parts.hostname
140
+ if not host:
141
+ raise FetchRefused('URL has no host')
142
+ if allow_private is True:
143
+ return parts
144
+ try:
145
+ port = parts.port or (443 if parts.scheme == 'https' else 80)
146
+ except ValueError as e:
147
+ raise FetchRefused('bad port: %s' % e)
148
+ # A literal address needs no DNS; check it directly (also catches
149
+ # bracketed IPv6 literals, which urlsplit already unbrackets).
150
+ try:
151
+ literal = ipaddress.ip_address(host)
152
+ except ValueError:
153
+ literal = None
154
+ if literal is not None:
155
+ if _is_forbidden_address(literal) and not _private_exempt(host, [host], allow_private):
156
+ raise FetchRefused('address %s is not a public address' % host)
157
+ return parts
158
+ try:
159
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
160
+ except socket.gaierror as e:
161
+ raise FetchNetworkError('cannot resolve %s: %s' % (host, e))
162
+ if not infos:
163
+ raise FetchNetworkError('cannot resolve %s' % host)
164
+ addrs = [str(info[4][0]).split('%', 1)[0] for info in infos]
165
+ if _private_exempt(host, addrs, allow_private):
166
+ return parts
167
+ # Every answer must pass: a name with one public and one private A record
168
+ # would otherwise be a coin-flip on which the socket layer picks.
169
+ for addr in addrs:
170
+ try:
171
+ ip = ipaddress.ip_address(addr)
172
+ except ValueError:
173
+ raise FetchRefused('unparseable resolved address %r for %s' % (addr, host))
174
+ if _is_forbidden_address(ip):
175
+ raise FetchRefused('%s resolves to non-public address %s' % (host, addr))
176
+ return parts
177
+
178
+
179
+ class _CheckedRedirectHandler(urllib.request.HTTPRedirectHandler):
180
+ """Re-run check_url() on every redirect target.
181
+
182
+ urllib follows 3xx by default and would happily hop from a vetted public
183
+ host to ``http://127.0.0.1/``; the first request's check says nothing
184
+ about where the chain ends.
185
+ """
186
+
187
+ max_redirections = MAX_REDIRECTS
188
+
189
+ def __init__(self, allow_private: bool) -> None:
190
+ super().__init__()
191
+ self.allow_private = allow_private
192
+
193
+ def http_error_302(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
194
+ # Hook the status handler rather than redirect_request(): the stdlib
195
+ # runs its own scheme check (raising a plain HTTPError) *before*
196
+ # redirect_request is consulted, and we want our FetchRefused to be
197
+ # the answer for every disallowed target, not just private hosts.
198
+ loc = headers.get('location') or headers.get('uri')
199
+ if loc:
200
+ newurl = urllib.parse.urljoin(req.full_url, loc.split(' ', 1)[0].strip())
201
+ check_url(newurl, allow_private=self.allow_private)
202
+ return super().http_error_302(req, fp, code, msg, headers)
203
+
204
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
205
+
206
+
207
+ def fetch(
208
+ url: str,
209
+ *,
210
+ max_bytes: int,
211
+ timeout: float = DEFAULT_TIMEOUT,
212
+ allow_private: Any = False,
213
+ method: str = 'GET',
214
+ data: Optional[bytes] = None,
215
+ headers: Optional[Mapping[str, str]] = None,
216
+ ) -> tuple[bytes, dict[str, str]]:
217
+ """Fetch ``url`` under the policy described in the module docstring.
218
+
219
+ Returns ``(body, headers)`` where headers is a plain dict with the
220
+ original header names. ``max_bytes`` is mandatory on purpose: every
221
+ caller must state how much it is prepared to hold in memory.
222
+
223
+ ``allow_private`` is False (full policy), True (no address policy at
224
+ all: operator-configured URLs only) or a collection of hostnames / IP
225
+ literals that are exempt while everything else -- including redirect
226
+ targets -- still has to be public.
227
+ """
228
+ if max_bytes is None or int(max_bytes) < 0:
229
+ raise ValueError('max_bytes must be a non-negative int')
230
+ max_bytes = int(max_bytes)
231
+ check_url(url, allow_private=allow_private)
232
+
233
+ hdrs = {'User-Agent': USER_AGENT}
234
+ if headers:
235
+ hdrs.update(headers)
236
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper())
237
+ opener = urllib.request.build_opener(_CheckedRedirectHandler(allow_private))
238
+ try:
239
+ resp = opener.open(req, timeout=timeout)
240
+ except FetchError:
241
+ # Raised from inside the redirect handler; urllib does not wrap it.
242
+ raise
243
+ except urllib.error.HTTPError as e:
244
+ # Bound the error body too; an error page can be as hostile as a 200.
245
+ try:
246
+ body = e.read(max_bytes + 1)
247
+ except Exception:
248
+ body = b''
249
+ finally:
250
+ e.close() # the error response holds the socket until it is closed
251
+ if len(body) > max_bytes:
252
+ raise FetchTooLarge('error body exceeds %d bytes' % max_bytes)
253
+ raise FetchHTTPError(e.code, str(e.reason), body, dict(e.headers.items()) if e.headers else {})
254
+ except urllib.error.URLError as e:
255
+ # urllib wraps the redirect handler's refusal in URLError in some
256
+ # code paths; unwrap so callers see the policy error, not 'network'.
257
+ if isinstance(e.reason, FetchError):
258
+ raise e.reason
259
+ raise FetchNetworkError(str(e.reason))
260
+ except (socket.timeout, TimeoutError) as e:
261
+ raise FetchNetworkError('timeout: %s' % e)
262
+ except (http.client.HTTPException, OSError, ValueError) as e:
263
+ raise FetchNetworkError('%s: %s' % (type(e).__name__, e))
264
+
265
+ with resp:
266
+ resp_headers = dict(resp.headers.items())
267
+ clen = resp.headers.get('Content-Length')
268
+ # A HEAD response's Content-Length describes the body a GET *would*
269
+ # carry, not bytes on this connection, so the pre-check only applies
270
+ # to methods that actually return a body.
271
+ if clen is not None and method.upper() != 'HEAD':
272
+ try:
273
+ if int(clen) > max_bytes:
274
+ raise FetchTooLarge('Content-Length %s exceeds %d bytes' % (clen, max_bytes))
275
+ except ValueError:
276
+ pass # garbage Content-Length: fall through to the read cap
277
+ try:
278
+ body = resp.read(max_bytes + 1)
279
+ except (http.client.HTTPException, OSError) as e:
280
+ raise FetchNetworkError('read failed: %s: %s' % (type(e).__name__, e))
281
+ if len(body) > max_bytes:
282
+ raise FetchTooLarge('body exceeds %d bytes' % max_bytes)
283
+ return body, resp_headers
@@ -0,0 +1,23 @@
1
+ """The relay rung: DIDComm JWEs in NIP-59 gift wraps over nostr relays.
2
+
3
+ One rung of the transport ladder (router.py decides the order; this package
4
+ never does). It is the rung that always exists, so the router delegates its
5
+ inbox, thread waiters and acknowledgment ledger to it. Everything that knows
6
+ a relay, a wrap kind or a subscription filter lives here and nowhere else in
7
+ the node:
8
+
9
+ core.py RelayRung - the object, its state, and how it is built
10
+ wire.py wrap/fragment policy: which kind, which created_at, how big
11
+ outbound.py send, request/converse, thread waiters, where a stored wrap goes
12
+ listen.py one subscription per relay, reconnects, readiness
13
+ inbound.py unwrap, dedup, reassemble, open the envelope, deliver
14
+ ledger.py please_ack / ack: what was sent and is still owed an answer
15
+ shape.py the Protocol the mixins share
16
+
17
+ `NostrTransport` is the historical name of RelayRung and stays importable.
18
+ """
19
+ from .core import RelayRung
20
+
21
+ NostrTransport = RelayRung
22
+
23
+ __all__ = ['RelayRung', 'NostrTransport']
@@ -0,0 +1,87 @@
1
+ """Per-relay cooldown after a relay says it is being asked too much.
2
+
3
+ A public relay answers an event it will not take with an OK false whose
4
+ reason is prefixed as NIP-01 suggests: `rate-limited:` and `banned:` are the
5
+ two that mean "stop sending for a while", and a handshake refused with HTTP
6
+ 429 means the same before a word is spoken - as does a 503, which is what a
7
+ relay that has banned the address answers once it stops talking at all. Publishing on regardless turns a
8
+ minute of rate-limiting into a ban, and three nodes behind one address did
9
+ exactly that within a minute of starting. The rung therefore holds such a
10
+ relay out of publishing for a window that doubles on each repeat, keeps
11
+ listening on it, and records the one fault that matters - that the relay
12
+ was cooled, until when, and why - instead of one fault per refused wrap.
13
+
14
+ Pure bookkeeping over an injected clock; no I/O, so a test drives it with
15
+ reasons alone.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import threading
21
+ from typing import Callable, Optional, TypedDict
22
+
23
+ THROTTLE_PREFIXES: tuple[str, ...] = ('rate-limited', 'banned')
24
+ _HTTP_429 = re.compile(r'\bHTTP/\d(?:\.\d)? (?:429|503)\b') # 429 and 503 at the handshake
25
+
26
+
27
+ class Cooling(TypedDict):
28
+ """A relay held out of publishing: until when, why, and how many times running."""
29
+ until: int
30
+ why: str
31
+ strikes: int
32
+
33
+
34
+ def is_throttle(reason: str) -> bool:
35
+ """Whether an OK reason or connection error means 'you, specifically, stop for now'."""
36
+ head = reason.split(':', 1)[0].strip().lower()
37
+ return head in THROTTLE_PREFIXES or bool(_HTTP_429.search(reason))
38
+
39
+
40
+ class RelayCooldown:
41
+ """Which relays are held, for how long, and the doubling that decides it."""
42
+
43
+ def __init__(self, clock: Callable[[], int], first: int = 60, longest: int = 900) -> None:
44
+ self.clock: Callable[[], int] = clock
45
+ self.first: int = first # the first window, seconds
46
+ self.longest: int = longest # the cap the doubling stops at
47
+ self._held: dict[str, Cooling] = {}
48
+ self._lock: threading.Lock = threading.Lock()
49
+
50
+ def refused(self, url: str, reason: str) -> Optional[Cooling]:
51
+ """Note a refusal. Returns the Cooling it started when the reason was a
52
+ throttle and the relay was not already held; None otherwise."""
53
+ if not is_throttle(reason):
54
+ return None
55
+ now = self.clock()
56
+ with self._lock:
57
+ held = self._held.get(url)
58
+ if held is not None and held['until'] > now:
59
+ return None # already cooling; the refusal is the old one echoing
60
+ strikes = (held['strikes'] + 1) if held is not None else 1
61
+ window = min(self.longest, self.first * (2 ** (strikes - 1)))
62
+ entry = Cooling(until=now + window, why=reason, strikes=strikes)
63
+ self._held[url] = entry
64
+ return entry
65
+
66
+ def accepted(self, url: str) -> None:
67
+ """An accepted publish after a cooling ended: the next strike starts over."""
68
+ with self._lock:
69
+ held = self._held.get(url)
70
+ if held is not None and held['until'] <= self.clock():
71
+ del self._held[url]
72
+
73
+ def holding(self, url: str) -> Optional[Cooling]:
74
+ """The Cooling on a relay if it is still in force."""
75
+ with self._lock:
76
+ held = self._held.get(url)
77
+ return held if held is not None and held['until'] > self.clock() else None
78
+
79
+ def open(self, urls: list[str]) -> list[str]:
80
+ """The relays in `urls` not currently held, in order."""
81
+ return [u for u in urls if self.holding(u) is None]
82
+
83
+ def state(self) -> dict[str, Cooling]:
84
+ """Every relay held right now."""
85
+ now = self.clock()
86
+ with self._lock:
87
+ return {u: dict(c) for u, c in self._held.items() if c['until'] > now} # type: ignore[misc]
@@ -0,0 +1,138 @@
1
+ """RelayRung: the relay rung's state and construction. Behaviour is in the
2
+ sibling mixins; this file is what an operator or a test instantiates.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import collections
7
+ import queue
8
+ import threading
9
+ import time
10
+ from typing import Any, Callable, Counter, Deque, Mapping, Optional, Sequence
11
+
12
+ from ...interfaces import NostrEvent, RelayHealthRow
13
+ from .. import const
14
+ from ..ladder import Fault, FaultLog
15
+ from .cooldown import RelayCooldown
16
+ from ..const import Inbound
17
+ from . import wire
18
+ from .types import AckVerdict, AwaitingEntry, Partial
19
+ from .inbound import InboundMixin
20
+ from .ledger import LedgerMixin
21
+ from .listen import ListenMixin
22
+ from .outbound import OutboundMixin
23
+
24
+
25
+ class RelayRung(OutboundMixin, ListenMixin, InboundMixin, LedgerMixin):
26
+ scheme: str = 'nostr'
27
+
28
+ def __init__(
29
+ self,
30
+ keystore: Any,
31
+ peers: Any,
32
+ relay_urls: Sequence[str],
33
+ clock: Optional[Callable[[], int]] = None,
34
+ timeout: float = 15.0,
35
+ seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = None,
36
+ ack_store: Optional[Any] = None,
37
+ seen_max: int = const.SEEN_MAX,
38
+ ) -> None:
39
+ self.ks: Any = keystore
40
+ self.seen_max: int = seen_max # wrap ids remembered for dedup before the oldest is forgotten
41
+ self.peers: Any = peers
42
+ # Stored messages sent and not yet acknowledged, by message id. On
43
+ # disk when the node backs it with its accounts (put_awaiting /
44
+ # drop_awaiting / awaiting), because the peer may be offline longer
45
+ # than this process lives and the question survives a restart.
46
+ self.ack_store: Optional[Any] = ack_store
47
+ self.awaiting: dict[str, AwaitingEntry] = {}
48
+ if ack_store is not None:
49
+ for mid, peer, mtype, at, relays in ack_store.awaiting():
50
+ self.awaiting[mid] = AwaitingEntry(peer=peer, type=mtype, at=at, relays=relays, heard_at=None)
51
+ self.heard: dict[str, int] = {} # peer -> when it was last heard from
52
+ self.acked: Counter[str] = collections.Counter() # type -> stored messages acknowledged
53
+ self.acks_in: int = 0
54
+ self.acks_out: int = 0
55
+ self.unacked: Deque[AckVerdict] = collections.deque(maxlen=const.DROPPED_KEPT) # verdicts, newest last
56
+ self._unacked_new: list[AckVerdict] = [] # verdicts not yet taken by the compartment
57
+ self._acks: queue.Queue[tuple[str, str]] = queue.Queue() # (message id, peer) to acknowledge
58
+ # Stored wraps (kind 1059) are replayed by relays on every reconnect,
59
+ # for two days; a node that forgets what it opened across a restart
60
+ # would act on an old terminate after re-peering. The store is a
61
+ # pair (has(wid), add(wid, at)) the node backs with its accounts.
62
+ self.seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = seen_store
63
+ self.wraps_in: Counter[Optional[int]] = collections.Counter() # inbound wraps by kind
64
+ self.wraps_seen_again: Counter[Optional[int]] = collections.Counter() # and those a dedup discarded
65
+ self.relays: list[str] = list(relay_urls)
66
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
67
+ # peer -> (its kind 10050 relays, when read). Read once an hour, not
68
+ # per message: a list that changed under us costs one hour of wraps
69
+ # going to the old inbox, which the relay replay horizon covers.
70
+ self._dm_relays: dict[str, tuple[list[str], int]] = {}
71
+ self.timeout: float = timeout
72
+ self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=const.INBOX_MAX)
73
+ self._waiting: dict[str, queue.Queue[Inbound]] = {} # thid -> Queue
74
+ self._seen: set[str] = set() # wrap ids, so two relays deliver once
75
+ self._seen_order: Deque[str] = collections.deque() # the same ids, oldest first, for the cap
76
+ self._partials: dict[tuple[str, str], Partial] = {} # (sender, fragment id) -> pieces of a large envelope
77
+ self._lock: threading.Lock = threading.Lock()
78
+ self._stop: threading.Event = threading.Event()
79
+ self._threads: list[threading.Thread] = []
80
+ # What this rung refused or lost, with reasons; the ladder reads it
81
+ # through faults, Link exports it, and `meshctl watch` reds on it.
82
+ self.faults: FaultLog = FaultLog('nostr', clock=self.clock, keep=const.DROPPED_KEPT)
83
+ # Relays that asked to be left alone for a while; see cooldown.py.
84
+ self.cooldown: RelayCooldown = RelayCooldown(self.clock)
85
+ # One entry per relay, for the operator: whether the subscription is up,
86
+ # when it last delivered, how often it has had to reconnect and why.
87
+ self.health: dict[str, RelayHealthRow] = {
88
+ url: {
89
+ 'connected': False,
90
+ # Whether the relay ANSWERED the subscription, which is not the
91
+ # same as having sent it. A kind 21059 is ephemeral: a wrap a
92
+ # peer sends before the REQ is registered is fanned out to
93
+ # nobody and no relay kept a copy, so a node that believes it
94
+ # is listening one round trip early loses exactly the live
95
+ # control traffic it cannot ask for again.
96
+ 'confirmed': False,
97
+ 'since': None,
98
+ 'last_event_at': None,
99
+ 'reconnects': 0,
100
+ 'last_error': None,
101
+ }
102
+ for url in self.relays
103
+ }
104
+
105
+
106
+ def unfinished(self) -> int:
107
+ """Envelopes half-arrived: fragments held with pieces still missing.
108
+
109
+ A number the operator reads, so it is asked for rather than reached in
110
+ for. The compartment that reports it has no business in `_partials`.
111
+ """
112
+ return len(self._partials)
113
+
114
+ def _sign_auth(self, relay_url: str, challenge: str) -> NostrEvent:
115
+ """Sign a NIP-42 kind 22242 authentication event using the node's key."""
116
+ return wire.auth_event(self.ks.node_sk, int(self.clock()), relay_url, challenge)
117
+
118
+ def _drop(self, reason: str, wrap_id: Optional[str]) -> None:
119
+ self.faults.record(reason, wrap_id)
120
+
121
+ def recent_faults(self, since: Optional[int] = None) -> list[Fault]:
122
+ """What this rung refused or lost, newest last, with reasons."""
123
+ return self.faults.recent(since)
124
+
125
+ @property
126
+ def drop_counts(self) -> Counter[str]:
127
+ """Fault family -> how many, ever."""
128
+ return self.faults.counts
129
+
130
+ @property
131
+ def dropped(self) -> Deque[tuple[str, Optional[str]]]:
132
+ """(reason, wrap_id) of the most recent faults, oldest first."""
133
+ return collections.deque(((f['reason'], f['ident']) for f in self.faults.recent()), maxlen=self.faults.maxlen)
134
+
135
+
136
+ @staticmethod
137
+ def _fragments(jwe: Mapping[str, Any]) -> list[Any]:
138
+ return wire.fragments(jwe)