fleetmesh-node 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
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()
|