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
node/transport/ws.py ADDED
@@ -0,0 +1,502 @@
1
+ """Enough of RFC 6455 for a node: a client, a server, text frames, ping and close.
2
+
3
+ Standard library only, blocking sockets with timeouts. scripts/nostr.py has a
4
+ smaller version of the client half for publishing one event; this one is for
5
+ a process that holds connections open: subscriptions on a relay, and the
6
+ relay face this node itself presents. Fragmented messages are reassembled;
7
+ binary frames are accepted and returned as bytes; a ping is answered on the
8
+ spot; a close frame is answered and raised as ConnectionClosed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import enum
14
+ import errno
15
+ import hashlib
16
+ import os
17
+ import socket
18
+ import ssl
19
+ import struct
20
+ import threading
21
+ import time
22
+ from typing import Any, Callable, Mapping, Optional
23
+ from urllib.parse import urlparse
24
+
25
+ GUID: bytes = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
26
+ class Opcode(enum.IntEnum):
27
+ """RFC 6455 frame opcodes. An IntEnum so the names read in a match
28
+ statement as value patterns and still compare equal to the raw ints the
29
+ frame decoder yields and that callers pass to send()."""
30
+ CONT = 0x0
31
+ TEXT = 0x1
32
+ BINARY = 0x2
33
+ CLOSE = 0x8
34
+ PING = 0x9
35
+ PONG = 0xA
36
+
37
+
38
+ # Module-level aliases keep the existing spelling used across the tree.
39
+ TEXT: int = Opcode.TEXT
40
+ BINARY: int = Opcode.BINARY
41
+ CLOSE: int = Opcode.CLOSE
42
+ PING: int = Opcode.PING
43
+ PONG: int = Opcode.PONG
44
+ CONT: int = Opcode.CONT
45
+ MAX_FRAME: int = 16 * 1024 * 1024
46
+ # The whole HTTP upgrade, either direction. Headers are a few hundred bytes.
47
+ HANDSHAKE_MAX: int = 64 * 1024
48
+
49
+
50
+ class ConnectionClosed(ConnectionError):
51
+ pass
52
+
53
+
54
+ class PlainHTTP(Exception):
55
+ """A request on the websocket port that did not ask to upgrade. The relay
56
+ face answers NIP-11 to these, and Blossom handles blob endpoints."""
57
+
58
+ def __init__(
59
+ self,
60
+ path: str,
61
+ headers: dict[bytes, bytes],
62
+ method: str = 'GET',
63
+ initial_body: bytes = b'',
64
+ sock: Optional[socket.socket] = None,
65
+ ) -> None:
66
+ super().__init__(path)
67
+ self.path: str = path
68
+ self.headers: dict[bytes, bytes] = headers
69
+ self.method: str = method
70
+ self.initial_body: bytes = initial_body
71
+ self.sock: Optional[socket.socket] = sock
72
+
73
+
74
+ def http_response(
75
+ sock: socket.socket,
76
+ status: str,
77
+ body: bytes = b'',
78
+ content_type: str = 'application/json',
79
+ headers: Optional[Mapping[str, str]] = None,
80
+ ) -> None:
81
+ cl = str(len(body))
82
+ extra_hdrs: list[tuple[str, str]] = []
83
+ if headers:
84
+ for k, v in headers.items():
85
+ if k.lower() == 'content-length':
86
+ cl = str(v)
87
+ elif k.lower() == 'content-type':
88
+ content_type = str(v)
89
+ else:
90
+ extra_hdrs.append((k, str(v)))
91
+
92
+ hdrs = [
93
+ 'HTTP/1.1 %s' % status,
94
+ 'Content-Type: %s' % content_type,
95
+ 'Content-Length: %s' % cl,
96
+ 'Access-Control-Allow-Origin: *',
97
+ 'Access-Control-Allow-Methods: GET, HEAD, PUT, POST, DELETE, OPTIONS',
98
+ 'Access-Control-Allow-Headers: Authorization, Content-Type, X-SHA-256, X-CID',
99
+ 'Connection: close',
100
+ ]
101
+ for k, v in extra_hdrs:
102
+ hdrs.append('%s: %s' % (k, v))
103
+ head = '\r\n'.join(hdrs) + '\r\n\r\n'
104
+ sock.sendall(head.encode('latin1') + body)
105
+
106
+
107
+ def read_body(conn: socket.socket, req: PlainHTTP, max_bytes: int = 64 * 1024 * 1024) -> bytes:
108
+ """Read the full HTTP body from connection based on Content-Length."""
109
+ cl = req.headers.get(b'content-length')
110
+ if not cl:
111
+ return req.initial_body
112
+ try:
113
+ length = int(cl.decode('ascii'))
114
+ except (ValueError, TypeError):
115
+ return req.initial_body
116
+
117
+ if length > max_bytes:
118
+ raise ValueError('request body length %d exceeds limit %d' % (length, max_bytes))
119
+
120
+ buf = bytearray(req.initial_body)
121
+ while len(buf) < length:
122
+ chunk = conn.recv(min(65536, length - len(buf)))
123
+ if not chunk:
124
+ break
125
+ buf.extend(chunk)
126
+ return bytes(buf)
127
+
128
+
129
+ def _read_exact(sock: socket.socket | _Prefixed, n: int) -> bytes:
130
+ buf = b''
131
+ while len(buf) < n:
132
+ chunk = sock.recv(n - len(buf))
133
+ if not chunk:
134
+ raise ConnectionClosed('peer closed the connection')
135
+ buf += chunk
136
+ return buf
137
+
138
+
139
+ def _mask(payload: bytes, key: bytes) -> bytes:
140
+ """XOR every byte of payload with the 4-byte key, repeating (RFC 6455 §5.3).
141
+
142
+ Done as one wide integer XOR rather than a byte-at-a-time generator: the
143
+ result is identical and the work moves into C. Measured 6x faster on a
144
+ 50-byte frame and 20-30x on 1KiB-1MiB, which is where a relay spends
145
+ its time when it is busy.
146
+ """
147
+ n = len(payload)
148
+ if not n:
149
+ return payload
150
+ full = key * (n // 4) + key[:n % 4]
151
+ return (int.from_bytes(payload, 'big') ^ int.from_bytes(full, 'big')).to_bytes(n, 'big')
152
+
153
+
154
+ def encode_frame(opcode: int, payload: bytes, mask: bool) -> bytes:
155
+ head = bytes([0x80 | opcode])
156
+ n = len(payload)
157
+ flag = 0x80 if mask else 0
158
+ if n < 126:
159
+ head += bytes([flag | n])
160
+ elif n < (1 << 16):
161
+ head += bytes([flag | 126]) + struct.pack('!H', n)
162
+ else:
163
+ head += bytes([flag | 127]) + struct.pack('!Q', n)
164
+ if mask:
165
+ key = os.urandom(4)
166
+ return head + key + _mask(payload, key)
167
+ return head + payload
168
+
169
+
170
+ def decode_frame(sock: socket.socket | _Prefixed) -> tuple[bool, int, bytes]:
171
+ """(fin, opcode, payload) for one frame, unmasked."""
172
+ b1, b2 = _read_exact(sock, 2)
173
+ fin, opcode = bool(b1 & 0x80), b1 & 0x0F
174
+ masked, n = bool(b2 & 0x80), b2 & 0x7F
175
+ if n == 126:
176
+ n = struct.unpack('!H', _read_exact(sock, 2))[0]
177
+ elif n == 127:
178
+ n = struct.unpack('!Q', _read_exact(sock, 8))[0]
179
+ if n > MAX_FRAME:
180
+ raise ConnectionClosed('frame of %d bytes refused' % n)
181
+ key = _read_exact(sock, 4) if masked else None
182
+ payload = _read_exact(sock, n)
183
+ if key:
184
+ payload = _mask(payload, key)
185
+ return fin, opcode, payload
186
+
187
+
188
+ class WebSocket:
189
+ """One connection after the handshake. Clients mask, servers do not."""
190
+
191
+ def __init__(self, sock: socket.socket | _Prefixed, client_side: bool) -> None:
192
+ self.sock: socket.socket | _Prefixed = sock
193
+ self.mask: bool = client_side
194
+ self._lock: threading.Lock = threading.Lock()
195
+ self.closed: bool = False
196
+ self.last_rx: float = time.time() # any frame at all, control frames included
197
+
198
+ def ping(self, payload: bytes = b'') -> None:
199
+ self.send(PING, payload)
200
+
201
+ def idle(self) -> float:
202
+ """Seconds since the peer last sent any frame."""
203
+ return time.time() - self.last_rx
204
+
205
+ def send(self, opcode: int, payload: bytes) -> None:
206
+ with self._lock:
207
+ self.sock.sendall(encode_frame(opcode, payload, self.mask))
208
+
209
+ def send_text(self, text: str) -> None:
210
+ self.send(TEXT, text.encode('utf-8'))
211
+
212
+ def recv(self) -> tuple[int, bytes]:
213
+ """The next complete message: (opcode, payload). Handles control frames.
214
+
215
+ MAX_FRAME bounds one frame in decode_frame; a message is any number of
216
+ continuation frames, and the sum was not bounded, so a peer could send
217
+ a megabyte at a time for as long as it liked and the list here grew
218
+ with it. The same cap applies to the whole message.
219
+ """
220
+ parts: list[bytes] = []
221
+ first: Optional[int] = None
222
+ total = 0
223
+ while True:
224
+ fin, opcode, payload = decode_frame(self.sock)
225
+ self.last_rx = time.time()
226
+ # Control frames may arrive between the fragments of a message
227
+ # (RFC 6455 §5.4), so they are answered here and never touch the
228
+ # buffer; anything that is not CONT begins a new message.
229
+ match opcode:
230
+ case Opcode.PING:
231
+ self.send(PONG, payload)
232
+ continue
233
+ case Opcode.PONG:
234
+ continue
235
+ case Opcode.CLOSE:
236
+ self.close(echo=payload[:2])
237
+ raise ConnectionClosed('close frame received')
238
+ case Opcode.CONT:
239
+ parts.append(payload)
240
+ total += len(payload)
241
+ case _:
242
+ first = opcode
243
+ parts = [payload]
244
+ total = len(payload)
245
+ if total > MAX_FRAME:
246
+ self.close(echo=struct.pack('!H', 1009)) # 1009: message too big
247
+ raise ConnectionClosed('message larger than %d bytes' % MAX_FRAME)
248
+ if fin:
249
+ assert first is not None
250
+ return first, b''.join(parts)
251
+
252
+ def recv_text(self) -> str:
253
+ opcode, payload = self.recv()
254
+ return payload.decode('utf-8') if opcode == TEXT else payload.decode('utf-8', errors='replace')
255
+
256
+ def close(self, echo: bytes = b'') -> None:
257
+ if self.closed:
258
+ return
259
+ self.closed = True
260
+ try:
261
+ self.send(CLOSE, echo or struct.pack('!H', 1000))
262
+ except OSError:
263
+ pass
264
+ try:
265
+ self.sock.close()
266
+ except OSError:
267
+ pass
268
+
269
+
270
+ def connect(url: str, timeout: float = 15.0) -> WebSocket:
271
+ u = urlparse(url)
272
+ host = u.hostname or '127.0.0.1'
273
+ port = u.port or (443 if u.scheme == 'wss' else 80)
274
+ path = u.path or '/'
275
+ raw = socket.create_connection((host, port), timeout=timeout)
276
+ if u.scheme == 'wss':
277
+ try:
278
+ sock: socket.socket | ssl.SSLSocket = ssl.create_default_context().wrap_socket(raw, server_hostname=host)
279
+ except Exception:
280
+ raw.close()
281
+ raise
282
+ else:
283
+ sock = raw
284
+
285
+ try:
286
+ key = base64.b64encode(os.urandom(16))
287
+ sock.sendall(b'GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
288
+ b'Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n'
289
+ % (path.encode(), host.encode(), key))
290
+ resp = b''
291
+ while b'\r\n\r\n' not in resp:
292
+ chunk = sock.recv(4096)
293
+ if not chunk:
294
+ raise ConnectionClosed('no handshake response')
295
+ resp += chunk
296
+ if len(resp) > HANDSHAKE_MAX:
297
+ # The server side has had this cap since the start; a relay
298
+ # that answers the upgrade with an endless header is the
299
+ # same shape of peer as a client that sends one.
300
+ raise ConnectionClosed('handshake too large')
301
+ head, _, rest = resp.partition(b'\r\n\r\n')
302
+ lines = head.split(b'\r\n')
303
+ if b' 101 ' not in lines[0]:
304
+ raise ConnectionError('handshake refused: %s' % lines[0].decode(errors='replace'))
305
+ want = base64.b64encode(hashlib.sha1(key + GUID, usedforsecurity=False).digest())
306
+ accept = [l.split(b':', 1)[1].strip() for l in lines[1:] if l.lower().startswith(b'sec-websocket-accept:')]
307
+ if not accept or accept[0] != want:
308
+ raise ConnectionError('bad Sec-WebSocket-Accept')
309
+ final_sock: socket.socket | _Prefixed = _Prefixed(sock, rest) if rest else sock
310
+ return WebSocket(final_sock, client_side=True)
311
+ except Exception:
312
+ try:
313
+ sock.close()
314
+ except Exception:
315
+ pass
316
+ raise
317
+
318
+
319
+ class _Prefixed:
320
+ """A socket with bytes already read; the first recv drains them."""
321
+
322
+ def __init__(self, sock: socket.socket | ssl.SSLSocket, buf: bytes) -> None:
323
+ self._sock: socket.socket | ssl.SSLSocket = sock
324
+ self._buf: bytes = buf
325
+
326
+ def recv(self, n: int) -> bytes:
327
+ if self._buf:
328
+ out, self._buf = self._buf[:n], self._buf[n:]
329
+ return out
330
+ return self._sock.recv(n)
331
+
332
+ def __getattr__(self, name: str) -> Any:
333
+ return getattr(self._sock, name)
334
+
335
+
336
+ def accept(sock: socket.socket) -> tuple[WebSocket, str]:
337
+ """Server side of the handshake on an accepted socket. Returns (WebSocket, path)."""
338
+ req = b''
339
+ while b'\r\n\r\n' not in req:
340
+ chunk = sock.recv(4096)
341
+ if not chunk:
342
+ raise ConnectionClosed('no handshake request')
343
+ req += chunk
344
+ if len(req) > HANDSHAKE_MAX:
345
+ raise ConnectionClosed('handshake too large')
346
+ head, _, rest = req.partition(b'\r\n\r\n')
347
+ lines = head.split(b'\r\n')
348
+ parts = lines[0].split(b' ')
349
+ method = parts[0].decode('latin1', errors='replace') if len(parts) > 0 else 'GET'
350
+ path = parts[1].decode('latin1', errors='replace') if len(parts) > 1 else '/'
351
+ headers: dict[bytes, bytes] = {}
352
+ for l in lines[1:]:
353
+ if b':' in l:
354
+ k, v = l.split(b':', 1)
355
+ headers[k.strip().lower()] = v.strip()
356
+ key = headers.get(b'sec-websocket-key')
357
+ if not key or b'websocket' not in headers.get(b'upgrade', b'').lower():
358
+ raise PlainHTTP(path, headers, method=method, initial_body=rest, sock=sock)
359
+ acc = base64.b64encode(hashlib.sha1(key + GUID, usedforsecurity=False).digest())
360
+ sock.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
361
+ b'Sec-WebSocket-Accept: %s\r\n\r\n' % acc)
362
+ final_sock: socket.socket | _Prefixed = _Prefixed(sock, rest) if rest else sock
363
+ return WebSocket(final_sock, client_side=False), path
364
+
365
+
366
+ class Server:
367
+ """Accepts connections and hands each to handler(ws, path) on its own thread."""
368
+
369
+ # How long a bind waits for a port it has just been using to come free.
370
+ # A node's relay face is on a recorded port so that clients keep its
371
+ # address across a restart, and `systemctl restart` closes that port and
372
+ # rebinds it within the same second. On a small machine the kernel can
373
+ # still hold the closed listener - observed on a 1-vCPU droplet, where
374
+ # `ss` showed LISTEN on the port with no owning process after close()
375
+ # had returned - and the node then died on startup with EADDRINUSE
376
+ # instead of coming back. SO_REUSEADDR does not cover this; waiting does.
377
+ BIND_WAIT: float = 10.0
378
+
379
+ def __init__(
380
+ self,
381
+ handler: Callable[[WebSocket, str], Any],
382
+ host: str = '127.0.0.1',
383
+ port: int = 0,
384
+ http_handler: Optional[Callable[..., Any]] = None,
385
+ bind_wait: Optional[float] = None,
386
+ ) -> None:
387
+ self.handler: Callable[[WebSocket, str], Any] = handler
388
+ # Callable[..., Any] because two call shapes are honoured: the newer
389
+ # (conn, PlainHTTP) and the older (conn, path, headers), tried in turn
390
+ # in _serve. A fixed signature would misdescribe one of them.
391
+ self.http_handler: Optional[Callable[..., Any]] = http_handler
392
+ self.sock: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
393
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
394
+ self._bind(host, port, self.BIND_WAIT if bind_wait is None else bind_wait)
395
+ self.sock.listen(16)
396
+ sock_name = self.sock.getsockname()
397
+ self.host: str = sock_name[0]
398
+ self.port: int = sock_name[1]
399
+ self._stop: threading.Event = threading.Event()
400
+ self._thread: threading.Thread = threading.Thread(target=self._loop, daemon=True)
401
+ self.connections: list[WebSocket] = []
402
+ self._conn_lock: threading.Lock = threading.Lock()
403
+ self.errors: list[str] = []
404
+
405
+ def _bind(self, host: str, port: int, wait: float) -> None:
406
+ """Bind, waiting out a port that is closing. Never waits on port 0.
407
+
408
+ Port 0 asks the kernel for anything free, so EADDRINUSE there is a
409
+ real fault and retrying it would only hide one. A named port is the
410
+ one an operator chose, and the only honest answer to "it is still
411
+ going" is to wait a moment and say so if it never comes.
412
+ """
413
+ deadline = time.time() + (wait if port else 0)
414
+ while True:
415
+ try:
416
+ self.sock.bind((host, port))
417
+ return
418
+ except OSError as e:
419
+ if e.errno != errno.EADDRINUSE or time.time() >= deadline:
420
+ if port and wait:
421
+ raise OSError(e.errno, '%s:%d is still in use after %gs - another '
422
+ 'process holds it, or the last one has not let go'
423
+ % (host, port, wait))
424
+ raise
425
+ time.sleep(0.2)
426
+
427
+ @property
428
+ def url(self) -> str:
429
+ return 'ws://%s:%d' % (self.host, self.port)
430
+
431
+ def start(self) -> Server:
432
+ self._thread.start()
433
+ return self
434
+
435
+ def _loop(self) -> None:
436
+ try:
437
+ self.sock.settimeout(0.2)
438
+ except OSError:
439
+ # Stopped before this thread was scheduled, which a test that
440
+ # starts and stops a face in the same breath does routinely. The
441
+ # accept below already treats a closed socket as the end; this line
442
+ # ran ahead of that guard and died with a traceback on stderr
443
+ # instead - noise in a log an operator reads to find real faults.
444
+ return
445
+ while not self._stop.is_set():
446
+ try:
447
+ conn, _ = self.sock.accept()
448
+ except socket.timeout:
449
+ continue
450
+ except OSError:
451
+ break
452
+ threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
453
+
454
+ def _serve(self, conn: socket.socket) -> None:
455
+ try:
456
+ conn.settimeout(60)
457
+ ws, path = accept(conn)
458
+ except PlainHTTP as req:
459
+ try:
460
+ if self.http_handler:
461
+ try:
462
+ self.http_handler(conn, req)
463
+ except TypeError:
464
+ self.http_handler(conn, req.path, req.headers)
465
+ else:
466
+ http_response(conn, '404 Not Found', b'{}')
467
+ finally:
468
+ try:
469
+ conn.close()
470
+ except OSError:
471
+ pass
472
+ return
473
+ except (ConnectionError, OSError):
474
+ conn.close()
475
+ return
476
+ with self._conn_lock:
477
+ self.connections.append(ws)
478
+ try:
479
+ self.handler(ws, path)
480
+ except (ConnectionError, OSError, ValueError):
481
+ pass
482
+ except Exception as e: # a handler bug closes one connection, not the server
483
+ with self._conn_lock:
484
+ self.errors.append('%s: %s' % (type(e).__name__, e))
485
+ del self.errors[:-200]
486
+ finally:
487
+ with self._conn_lock:
488
+ if ws in self.connections:
489
+ self.connections.remove(ws)
490
+ ws.close()
491
+
492
+ def stop(self) -> None:
493
+ self._stop.set()
494
+ try:
495
+ self.sock.close()
496
+ except OSError:
497
+ pass
498
+ with self._conn_lock:
499
+ conns = list(self.connections)
500
+ self.connections.clear()
501
+ for ws in conns:
502
+ ws.close()