personaltunnel-python-client 0.1.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.
- personaltunnel_client/__init__.py +29 -0
- personaltunnel_client/client.py +234 -0
- personaltunnel_client/crypto.py +229 -0
- personaltunnel_client/forward.py +183 -0
- personaltunnel_client/yamux.py +319 -0
- personaltunnel_python_client-0.1.0.dist-info/METADATA +9 -0
- personaltunnel_python_client-0.1.0.dist-info/RECORD +8 -0
- personaltunnel_python_client-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
personaltunnel_client — asyncio Python client library for the personaltunnel Go server.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from personaltunnel_client import PersonalTunnelClient, generate_keypair
|
|
8
|
+
|
|
9
|
+
# Generate a keypair (one-time setup — share your public key with the server operator)
|
|
10
|
+
priv_hex, pub_hex = generate_keypair()
|
|
11
|
+
|
|
12
|
+
async def main():
|
|
13
|
+
async with PersonalTunnelClient(
|
|
14
|
+
server_addr="vps.example.com",
|
|
15
|
+
control_port=7000,
|
|
16
|
+
local_addr="localhost:8000",
|
|
17
|
+
client_priv_key=priv_hex,
|
|
18
|
+
server_pub_key="<server_pub_hex>",
|
|
19
|
+
):
|
|
20
|
+
print("Tunnel active. Press Ctrl+C to stop.")
|
|
21
|
+
await asyncio.Event().wait() # run forever
|
|
22
|
+
|
|
23
|
+
asyncio.run(main())
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import PersonalTunnelClient
|
|
27
|
+
from .crypto import generate_keypair
|
|
28
|
+
|
|
29
|
+
__all__ = ["PersonalTunnelClient", "generate_keypair"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PersonalTunnelClient — asyncio client that connects to the personaltunnel
|
|
3
|
+
Go server, authenticates via the Nostr handshake, and forwards public HTTP
|
|
4
|
+
requests to a local service via yamux streams.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Awaitable, Callable, Optional
|
|
13
|
+
|
|
14
|
+
from .crypto import EncConn, HandshakeError, client_handshake
|
|
15
|
+
from .forward import handle_stream
|
|
16
|
+
from .yamux import YamuxSession
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PersonalTunnelClient:
|
|
22
|
+
"""
|
|
23
|
+
Asyncio client library for the personaltunnel Go server.
|
|
24
|
+
|
|
25
|
+
Connects to the server's control port, performs Nostr mutual-auth,
|
|
26
|
+
multiplexes incoming HTTP requests over a yamux session, and forwards
|
|
27
|
+
each request to a local service. Reconnects automatically with
|
|
28
|
+
exponential backoff on connection loss.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
# Async context manager (tunnel runs as a background task)
|
|
33
|
+
async with PersonalTunnelClient(
|
|
34
|
+
server_addr="vps.example.com",
|
|
35
|
+
control_port=7000,
|
|
36
|
+
local_addr="localhost:8000",
|
|
37
|
+
client_priv_key="<hex>",
|
|
38
|
+
server_pub_key="<hex>",
|
|
39
|
+
) as client:
|
|
40
|
+
await asyncio.sleep(3600)
|
|
41
|
+
|
|
42
|
+
# Explicit background task
|
|
43
|
+
client = PersonalTunnelClient(...)
|
|
44
|
+
task = await client.start()
|
|
45
|
+
await asyncio.sleep(3600)
|
|
46
|
+
await client.stop()
|
|
47
|
+
|
|
48
|
+
# Blocking run (never returns until stop() is called elsewhere)
|
|
49
|
+
await client.run()
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
server_addr: str,
|
|
55
|
+
control_port: int,
|
|
56
|
+
local_addr: str,
|
|
57
|
+
client_priv_key: str,
|
|
58
|
+
server_pub_key: str,
|
|
59
|
+
reconnect_interval: float = 2.0,
|
|
60
|
+
on_connect: Optional[Callable[[], Awaitable[None] | None]] = None,
|
|
61
|
+
on_disconnect: Optional[
|
|
62
|
+
Callable[[Optional[Exception]], Awaitable[None] | None]
|
|
63
|
+
] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Args:
|
|
67
|
+
server_addr: Hostname or IP of the VPS running the Go server.
|
|
68
|
+
control_port: Control port of the Go server (typically 7000).
|
|
69
|
+
local_addr: Local service to forward traffic to
|
|
70
|
+
(e.g. "localhost:8000").
|
|
71
|
+
client_priv_key: Hex-encoded 32-byte secp256k1 private key.
|
|
72
|
+
server_pub_key: Hex-encoded 32-byte x-only secp256k1 public key
|
|
73
|
+
of the server (from ``generate_keypair()``).
|
|
74
|
+
reconnect_interval: Base reconnect delay in seconds (doubles on
|
|
75
|
+
each failure, capped at 60 s).
|
|
76
|
+
on_connect: Optional async or sync callback invoked each
|
|
77
|
+
time the tunnel connects successfully.
|
|
78
|
+
on_disconnect: Optional async or sync callback invoked when
|
|
79
|
+
the tunnel disconnects. Receives the exception
|
|
80
|
+
that caused the disconnect (or None on clean close).
|
|
81
|
+
"""
|
|
82
|
+
self.server_addr = server_addr
|
|
83
|
+
self.control_port = control_port
|
|
84
|
+
self.local_addr = local_addr
|
|
85
|
+
self.reconnect_interval = reconnect_interval
|
|
86
|
+
self.on_connect = on_connect
|
|
87
|
+
self.on_disconnect = on_disconnect
|
|
88
|
+
|
|
89
|
+
# Pre-decode keys so errors surface at construction time.
|
|
90
|
+
self._client_priv_bytes: bytes = bytes.fromhex(client_priv_key)
|
|
91
|
+
self._server_pub_x: bytes = bytes.fromhex(server_pub_key)
|
|
92
|
+
|
|
93
|
+
if len(self._client_priv_bytes) != 32:
|
|
94
|
+
raise ValueError("client_priv_key must be a 32-byte (64 hex char) key")
|
|
95
|
+
if len(self._server_pub_x) != 32:
|
|
96
|
+
raise ValueError("server_pub_key must be a 32-byte (64 hex char) x-only key")
|
|
97
|
+
|
|
98
|
+
self._stop_event: asyncio.Event | None = None
|
|
99
|
+
self._task: asyncio.Task | None = None
|
|
100
|
+
self._active_session: YamuxSession | None = None
|
|
101
|
+
|
|
102
|
+
# ── Public API ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async def start(self) -> asyncio.Task:
|
|
105
|
+
"""
|
|
106
|
+
Start the tunnel in a background asyncio Task.
|
|
107
|
+
|
|
108
|
+
Returns the running Task so callers can await it or cancel it if needed.
|
|
109
|
+
"""
|
|
110
|
+
self._stop_event = asyncio.Event()
|
|
111
|
+
self._task = asyncio.create_task(
|
|
112
|
+
self._run_loop(), name="personaltunnel-client"
|
|
113
|
+
)
|
|
114
|
+
return self._task
|
|
115
|
+
|
|
116
|
+
async def stop(self) -> None:
|
|
117
|
+
"""Signal the client to stop and wait for the background task to finish."""
|
|
118
|
+
if self._stop_event:
|
|
119
|
+
self._stop_event.set()
|
|
120
|
+
if self._active_session:
|
|
121
|
+
await self._active_session.close()
|
|
122
|
+
if self._task and not self._task.done():
|
|
123
|
+
try:
|
|
124
|
+
await asyncio.wait_for(self._task, timeout=5.0)
|
|
125
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
126
|
+
self._task.cancel()
|
|
127
|
+
|
|
128
|
+
async def run(self) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Run the tunnel reconnect loop until stop() is called.
|
|
131
|
+
|
|
132
|
+
Useful as a top-level coroutine::
|
|
133
|
+
|
|
134
|
+
client = PersonalTunnelClient(...)
|
|
135
|
+
await client.run()
|
|
136
|
+
"""
|
|
137
|
+
self._stop_event = asyncio.Event()
|
|
138
|
+
await self._run_loop()
|
|
139
|
+
|
|
140
|
+
async def __aenter__(self) -> "PersonalTunnelClient":
|
|
141
|
+
await self.start()
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
145
|
+
await self.stop()
|
|
146
|
+
|
|
147
|
+
# ── Internal ─────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async def _run_loop(self) -> None:
|
|
150
|
+
"""Reconnect loop with exponential backoff."""
|
|
151
|
+
assert self._stop_event is not None
|
|
152
|
+
|
|
153
|
+
backoff = self.reconnect_interval
|
|
154
|
+
|
|
155
|
+
while not self._stop_event.is_set():
|
|
156
|
+
exc: Exception | None = None
|
|
157
|
+
try:
|
|
158
|
+
await self._connect_once()
|
|
159
|
+
backoff = self.reconnect_interval
|
|
160
|
+
except asyncio.CancelledError:
|
|
161
|
+
return
|
|
162
|
+
except (HandshakeError, ValueError) as e:
|
|
163
|
+
logger.error("tunnel: auth error: %s", e)
|
|
164
|
+
exc = e
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning("tunnel: disconnected (%r), retry in %.1fs", e, backoff)
|
|
167
|
+
exc = e
|
|
168
|
+
else:
|
|
169
|
+
logger.info("tunnel: session closed cleanly")
|
|
170
|
+
|
|
171
|
+
if self.on_disconnect:
|
|
172
|
+
await _maybe_await(self.on_disconnect(exc))
|
|
173
|
+
|
|
174
|
+
# Wait for backoff duration (wake immediately if stop() is called).
|
|
175
|
+
try:
|
|
176
|
+
await asyncio.wait_for(
|
|
177
|
+
asyncio.shield(self._stop_event.wait()),
|
|
178
|
+
timeout=backoff,
|
|
179
|
+
)
|
|
180
|
+
return # stop was requested
|
|
181
|
+
except asyncio.TimeoutError:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
if exc is not None:
|
|
185
|
+
backoff = min(backoff * 2, 60.0)
|
|
186
|
+
|
|
187
|
+
async def _connect_once(self) -> None:
|
|
188
|
+
"""Open one connection to the server and serve streams until closed."""
|
|
189
|
+
addr = f"{self.server_addr}:{self.control_port}"
|
|
190
|
+
logger.info("tunnel: connecting to %s", addr)
|
|
191
|
+
|
|
192
|
+
reader, writer = await asyncio.wait_for(
|
|
193
|
+
asyncio.open_connection(self.server_addr, self.control_port),
|
|
194
|
+
timeout=10.0,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
enc_conn: EncConn = await client_handshake(
|
|
199
|
+
reader,
|
|
200
|
+
writer,
|
|
201
|
+
self._client_priv_bytes,
|
|
202
|
+
self._server_pub_x,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
session = YamuxSession(enc_conn)
|
|
206
|
+
self._active_session = session
|
|
207
|
+
await session.start()
|
|
208
|
+
|
|
209
|
+
logger.info("tunnel: connected and authenticated to %s", addr)
|
|
210
|
+
if self.on_connect:
|
|
211
|
+
await _maybe_await(self.on_connect())
|
|
212
|
+
|
|
213
|
+
while not session.closed:
|
|
214
|
+
stream = await session.accept()
|
|
215
|
+
if stream is None:
|
|
216
|
+
break
|
|
217
|
+
asyncio.create_task(
|
|
218
|
+
handle_stream(stream, self.local_addr),
|
|
219
|
+
name=f"stream-{stream._stream_id}",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
finally:
|
|
223
|
+
self._active_session = None
|
|
224
|
+
writer.close()
|
|
225
|
+
try:
|
|
226
|
+
await writer.wait_closed()
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _maybe_await(result: object) -> None:
|
|
232
|
+
"""Await *result* if it is a coroutine, otherwise discard it."""
|
|
233
|
+
if inspect.isawaitable(result):
|
|
234
|
+
await result # type: ignore[arg-type]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nostr mutual-auth handshake and ChaCha20-Poly1305 encrypted connection.
|
|
3
|
+
|
|
4
|
+
Wire protocol (client side):
|
|
5
|
+
Step 1 receive: nonce_s (32) + server_pub_x_only (32)
|
|
6
|
+
Step 1 send: nonce_c (32) + client_pub_x_only (32)
|
|
7
|
+
Step 2 send: schnorr_sig(SHA256(nonce_s)) (64 bytes)
|
|
8
|
+
Step 2 receive: schnorr_sig(SHA256(nonce_c)) (64 bytes)
|
|
9
|
+
Step 3 receive: status byte (0x00 = OK, 0x01 = rejected)
|
|
10
|
+
Step 4: ECDH + HKDF → 32-byte ChaCha20-Poly1305 key
|
|
11
|
+
|
|
12
|
+
Crypto dependencies:
|
|
13
|
+
- coincurve (via bip-utils): secp256k1 Schnorr BIP340 sign/verify, ECDH
|
|
14
|
+
- cryptography: ChaCha20-Poly1305 AEAD, HKDF-SHA256
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import struct
|
|
23
|
+
|
|
24
|
+
import coincurve
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
26
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
27
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
28
|
+
|
|
29
|
+
_MAX_MESSAGE_SIZE = 1 << 20 # 1 MiB, matches Go's maxMessageSize
|
|
30
|
+
_HKDF_INFO = b"personaltunnel-v1"
|
|
31
|
+
_STATUS_OK = 0x00
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HandshakeError(Exception):
|
|
35
|
+
"""Raised when the Nostr mutual-auth handshake fails."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EncConn:
|
|
39
|
+
"""
|
|
40
|
+
Async ChaCha20-Poly1305 encrypted connection.
|
|
41
|
+
|
|
42
|
+
Wire format per message:
|
|
43
|
+
4 bytes big-endian uint32: length of ciphertext (incl. 16-byte AEAD tag)
|
|
44
|
+
N bytes ChaCha20-Poly1305 ciphertext
|
|
45
|
+
|
|
46
|
+
Nonces are 12-byte little-endian encodings of a uint64 counter (separate
|
|
47
|
+
send/recv sequences starting at 0), matching Go's encconn.go seqNonce().
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
reader: asyncio.StreamReader,
|
|
53
|
+
writer: asyncio.StreamWriter,
|
|
54
|
+
key: bytes,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._reader = reader
|
|
57
|
+
self._writer = writer
|
|
58
|
+
self._aead = ChaCha20Poly1305(key)
|
|
59
|
+
self._send_lock = asyncio.Lock()
|
|
60
|
+
self._send_seq: int = 0
|
|
61
|
+
self._recv_seq: int = 0
|
|
62
|
+
self._recv_buf = bytearray()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _seq_nonce(seq: int) -> bytes:
|
|
66
|
+
"""12-byte little-endian nonce from a uint64 counter (4 zero pad bytes)."""
|
|
67
|
+
return struct.pack("<Q", seq) + b"\x00\x00\x00\x00"
|
|
68
|
+
|
|
69
|
+
async def send_msg(self, plaintext: bytes) -> None:
|
|
70
|
+
"""Encrypt plaintext and write as a single length-prefixed message."""
|
|
71
|
+
async with self._send_lock:
|
|
72
|
+
nonce = self._seq_nonce(self._send_seq)
|
|
73
|
+
self._send_seq += 1
|
|
74
|
+
ciphertext = self._aead.encrypt(nonce, plaintext, None)
|
|
75
|
+
if len(ciphertext) > _MAX_MESSAGE_SIZE:
|
|
76
|
+
raise ValueError(f"message too large: {len(ciphertext)} bytes")
|
|
77
|
+
self._writer.write(struct.pack(">I", len(ciphertext)) + ciphertext)
|
|
78
|
+
await self._writer.drain()
|
|
79
|
+
|
|
80
|
+
async def recv_msg(self) -> bytes:
|
|
81
|
+
"""Read and decrypt the next length-prefixed message."""
|
|
82
|
+
length_bytes = await self._reader.readexactly(4)
|
|
83
|
+
msg_len = struct.unpack(">I", length_bytes)[0]
|
|
84
|
+
if msg_len == 0 or msg_len > _MAX_MESSAGE_SIZE:
|
|
85
|
+
raise ValueError(f"invalid message length: {msg_len}")
|
|
86
|
+
ciphertext = await self._reader.readexactly(msg_len)
|
|
87
|
+
nonce = self._seq_nonce(self._recv_seq)
|
|
88
|
+
self._recv_seq += 1
|
|
89
|
+
try:
|
|
90
|
+
return self._aead.decrypt(nonce, ciphertext, None)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise ValueError(f"decryption failed: {exc}") from exc
|
|
93
|
+
|
|
94
|
+
async def read(self, n: int) -> bytes:
|
|
95
|
+
"""
|
|
96
|
+
Read exactly n bytes, decrypting new messages as needed.
|
|
97
|
+
Excess plaintext from a decrypted message is retained in an internal
|
|
98
|
+
buffer for subsequent reads, matching Go's encConn.Read() behaviour.
|
|
99
|
+
"""
|
|
100
|
+
while len(self._recv_buf) < n:
|
|
101
|
+
chunk = await self.recv_msg()
|
|
102
|
+
self._recv_buf.extend(chunk)
|
|
103
|
+
data = bytes(self._recv_buf[:n])
|
|
104
|
+
del self._recv_buf[:n]
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
async def write(self, data: bytes) -> None:
|
|
108
|
+
"""Encrypt and send data (one encrypted message per call)."""
|
|
109
|
+
await self.send_msg(data)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Close the underlying transport."""
|
|
113
|
+
self._writer.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def client_handshake(
|
|
117
|
+
reader: asyncio.StreamReader,
|
|
118
|
+
writer: asyncio.StreamWriter,
|
|
119
|
+
client_priv_bytes: bytes,
|
|
120
|
+
expected_server_pub_x: bytes,
|
|
121
|
+
) -> EncConn:
|
|
122
|
+
"""
|
|
123
|
+
Perform the Nostr mutual-auth handshake as the client side.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
reader: asyncio StreamReader connected to the server control port.
|
|
127
|
+
writer: asyncio StreamWriter connected to the server control port.
|
|
128
|
+
client_priv_bytes: 32-byte raw secp256k1 private key.
|
|
129
|
+
expected_server_pub_x: 32-byte x-only Nostr pubkey of the server.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
An EncConn ready to be used as a yamux transport.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
HandshakeError: On pubkey mismatch, invalid signature, or server rejection.
|
|
136
|
+
"""
|
|
137
|
+
# ── Step 1 receive: nonce_s + server_pub_x_only ──────────────────────────
|
|
138
|
+
data = await reader.readexactly(64)
|
|
139
|
+
nonce_s: bytes = data[:32]
|
|
140
|
+
server_pub_x_only: bytes = data[32:]
|
|
141
|
+
|
|
142
|
+
if server_pub_x_only != expected_server_pub_x:
|
|
143
|
+
raise HandshakeError(
|
|
144
|
+
f"server pubkey mismatch: got {server_pub_x_only.hex()}, "
|
|
145
|
+
f"expected {expected_server_pub_x.hex()}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ── Step 1 send: nonce_c + client_pub_x_only ─────────────────────────────
|
|
149
|
+
nonce_c: bytes = os.urandom(32)
|
|
150
|
+
client_priv = coincurve.PrivateKey(secret=client_priv_bytes)
|
|
151
|
+
# x-only pubkey: 32-byte x-coordinate (BIP340 even-Y key)
|
|
152
|
+
client_pub_x_only: bytes = client_priv.public_key_xonly.format()
|
|
153
|
+
writer.write(nonce_c + client_pub_x_only)
|
|
154
|
+
await writer.drain()
|
|
155
|
+
|
|
156
|
+
# ── Step 2 send: schnorr_sig(SHA256(nonce_s)) ────────────────────────────
|
|
157
|
+
nonce_s_hash = hashlib.sha256(nonce_s).digest()
|
|
158
|
+
sig_c: bytes = client_priv.sign_schnorr(nonce_s_hash)
|
|
159
|
+
writer.write(sig_c)
|
|
160
|
+
await writer.drain()
|
|
161
|
+
|
|
162
|
+
# ── Step 2 receive: server signature over nonce_c ────────────────────────
|
|
163
|
+
sig_s = await reader.readexactly(64)
|
|
164
|
+
nonce_c_hash = hashlib.sha256(nonce_c).digest()
|
|
165
|
+
server_xonly_pub = coincurve.PublicKeyXOnly(server_pub_x_only)
|
|
166
|
+
if not server_xonly_pub.verify(sig_s, nonce_c_hash):
|
|
167
|
+
raise HandshakeError("server signature verification failed")
|
|
168
|
+
|
|
169
|
+
# ── Step 3 receive: status byte ──────────────────────────────────────────
|
|
170
|
+
status = await reader.readexactly(1)
|
|
171
|
+
if status[0] != _STATUS_OK:
|
|
172
|
+
raise HandshakeError(
|
|
173
|
+
f"server rejected connection (status {status[0]:#04x})"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ── Step 4: ECDH + HKDF → encryption key ─────────────────────────────────
|
|
177
|
+
shared_x = _ecdh_x(client_priv_bytes, server_pub_x_only)
|
|
178
|
+
key = _derive_key(shared_x, salt=nonce_s + nonce_c)
|
|
179
|
+
|
|
180
|
+
return EncConn(reader, writer, key)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ecdh_x(priv_bytes: bytes, peer_pub_x_only: bytes) -> bytes:
|
|
184
|
+
"""
|
|
185
|
+
Compute the x-coordinate of the ECDH shared point.
|
|
186
|
+
|
|
187
|
+
Peer's x-only key is converted to a compressed secp256k1 point using the
|
|
188
|
+
BIP340 even-Y convention (0x02 prefix) before scalar multiplication.
|
|
189
|
+
|
|
190
|
+
Equivalent to Go:
|
|
191
|
+
btcec.ScalarMultNonConst(&privKeyScalar, peerPoint, peerPoint)
|
|
192
|
+
sharedX := peerPoint.X.Bytes()
|
|
193
|
+
"""
|
|
194
|
+
# BIP340 x-only → even-Y compressed point (0x02 prefix)
|
|
195
|
+
peer_compressed = b"\x02" + peer_pub_x_only
|
|
196
|
+
peer_pub = coincurve.PublicKey(peer_compressed)
|
|
197
|
+
# multiply returns a new PublicKey; extract x-coordinate from compressed form
|
|
198
|
+
shared_pub = peer_pub.multiply(priv_bytes)
|
|
199
|
+
return shared_pub.format(compressed=True)[1:] # 32-byte x-coordinate
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _derive_key(ikm: bytes, salt: bytes) -> bytes:
|
|
203
|
+
"""
|
|
204
|
+
HKDF-SHA256 with info="personaltunnel-v1", output 32 bytes.
|
|
205
|
+
|
|
206
|
+
Equivalent to Go:
|
|
207
|
+
hkdf.New(sha256.New, sharedX, salt, []byte("personaltunnel-v1"))
|
|
208
|
+
"""
|
|
209
|
+
hkdf = HKDF(
|
|
210
|
+
algorithm=SHA256(),
|
|
211
|
+
length=32,
|
|
212
|
+
salt=salt,
|
|
213
|
+
info=_HKDF_INFO,
|
|
214
|
+
)
|
|
215
|
+
return hkdf.derive(ikm)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def generate_keypair() -> tuple[str, str]:
|
|
219
|
+
"""
|
|
220
|
+
Generate a new secp256k1 keypair suitable for use with personaltunnel.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
(privkey_hex, pubkey_hex) where pubkey_hex is the 32-byte x-only
|
|
224
|
+
(Nostr/BIP340) public key, both hex-encoded.
|
|
225
|
+
"""
|
|
226
|
+
priv = coincurve.PrivateKey()
|
|
227
|
+
priv_hex = priv.secret.hex()
|
|
228
|
+
pub_hex = priv.public_key_xonly.format().hex()
|
|
229
|
+
return priv_hex, pub_hex
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-stream HTTP request forwarding.
|
|
3
|
+
|
|
4
|
+
Each yamux stream carries one HTTP/1.1 request from the public internet
|
|
5
|
+
(already read and parsed by the Go server's proxy.go). This module reads
|
|
6
|
+
the request from the stream, forwards it to the local service, and writes
|
|
7
|
+
the HTTP/1.1 response back into the stream.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .yamux import YamuxStream
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Headers that must not be forwarded to the local service (hop-by-hop).
|
|
23
|
+
_HOP_BY_HOP = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"connection",
|
|
26
|
+
"keep-alive",
|
|
27
|
+
"proxy-authenticate",
|
|
28
|
+
"proxy-authorization",
|
|
29
|
+
"proxy-connection",
|
|
30
|
+
"te",
|
|
31
|
+
"trailers",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"upgrade",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def handle_stream(stream: "YamuxStream", local_addr: str) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Read one HTTP request from *stream*, forward it to *local_addr*, and write
|
|
41
|
+
the HTTP/1.1 response back. Always closes the stream when finished.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
stream: An open YamuxStream received from YamuxSession.accept().
|
|
45
|
+
local_addr: Host:port of the local service (e.g. "localhost:8000").
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
method, path, req_headers, body = await _read_http_request(stream)
|
|
49
|
+
|
|
50
|
+
fwd_headers = {
|
|
51
|
+
k: v
|
|
52
|
+
for k, v in req_headers.items()
|
|
53
|
+
if k.lower() not in _HOP_BY_HOP
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async with httpx.AsyncClient() as http:
|
|
57
|
+
resp = await http.request(
|
|
58
|
+
method,
|
|
59
|
+
f"http://{local_addr}{path}",
|
|
60
|
+
headers=fwd_headers,
|
|
61
|
+
content=body,
|
|
62
|
+
follow_redirects=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await _write_http_response(stream, resp)
|
|
66
|
+
logger.debug(
|
|
67
|
+
"forward: %s %s → %d (%d bytes)",
|
|
68
|
+
method,
|
|
69
|
+
path,
|
|
70
|
+
resp.status_code,
|
|
71
|
+
len(resp.content),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
except (httpx.ConnectError, httpx.TimeoutException, OSError) as exc:
|
|
75
|
+
logger.warning("forward: local service unreachable at %s: %r", local_addr, exc)
|
|
76
|
+
await _write_error(stream, 502, "local service unreachable")
|
|
77
|
+
|
|
78
|
+
except EOFError as exc:
|
|
79
|
+
logger.debug("forward: stream closed before request complete: %r", exc)
|
|
80
|
+
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
logger.debug("forward: unexpected error: %r", exc)
|
|
83
|
+
|
|
84
|
+
finally:
|
|
85
|
+
await stream.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _read_http_request(
|
|
89
|
+
stream: "YamuxStream",
|
|
90
|
+
) -> tuple[str, str, dict[str, str], bytes]:
|
|
91
|
+
"""
|
|
92
|
+
Read a full HTTP/1.1 request from *stream*.
|
|
93
|
+
|
|
94
|
+
Returns (method, path, headers_dict, body_bytes).
|
|
95
|
+
Raises EOFError if the stream closes before the request is complete.
|
|
96
|
+
"""
|
|
97
|
+
# Read headers section (up to and including the blank line).
|
|
98
|
+
buf = bytearray()
|
|
99
|
+
while True:
|
|
100
|
+
chunk = await stream.read(4096)
|
|
101
|
+
if not chunk:
|
|
102
|
+
raise EOFError("stream closed before HTTP headers complete")
|
|
103
|
+
buf.extend(chunk)
|
|
104
|
+
if b"\r\n\r\n" in buf:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
split_pos = buf.index(b"\r\n\r\n")
|
|
108
|
+
header_bytes = bytes(buf[:split_pos])
|
|
109
|
+
leftover = bytes(buf[split_pos + 4 :])
|
|
110
|
+
|
|
111
|
+
# Parse request line and headers.
|
|
112
|
+
lines = header_bytes.split(b"\r\n")
|
|
113
|
+
if not lines:
|
|
114
|
+
raise ValueError("empty HTTP request")
|
|
115
|
+
|
|
116
|
+
request_line = lines[0].decode("latin-1")
|
|
117
|
+
parts = request_line.split(" ", 2)
|
|
118
|
+
if len(parts) < 2:
|
|
119
|
+
raise ValueError(f"malformed request line: {request_line!r}")
|
|
120
|
+
method = parts[0]
|
|
121
|
+
path = parts[1]
|
|
122
|
+
|
|
123
|
+
headers: dict[str, str] = {}
|
|
124
|
+
for line in lines[1:]:
|
|
125
|
+
if b":" in line:
|
|
126
|
+
name, _, value = line.partition(b":")
|
|
127
|
+
headers[name.decode("latin-1").strip()] = value.decode("latin-1").strip()
|
|
128
|
+
|
|
129
|
+
# Read body.
|
|
130
|
+
content_length = int(
|
|
131
|
+
headers.get("Content-Length", headers.get("content-length", "0"))
|
|
132
|
+
)
|
|
133
|
+
body = bytearray(leftover[:content_length])
|
|
134
|
+
|
|
135
|
+
while len(body) < content_length:
|
|
136
|
+
remaining = content_length - len(body)
|
|
137
|
+
chunk = await stream.read(min(remaining, 65536))
|
|
138
|
+
if not chunk:
|
|
139
|
+
break
|
|
140
|
+
body.extend(chunk)
|
|
141
|
+
|
|
142
|
+
return method, path, headers, bytes(body)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _write_http_response(
|
|
146
|
+
stream: "YamuxStream", resp: httpx.Response
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Serialise *resp* as HTTP/1.1 and write it into *stream*."""
|
|
149
|
+
reason = resp.reason_phrase or ""
|
|
150
|
+
lines = [f"HTTP/1.1 {resp.status_code} {reason}"]
|
|
151
|
+
|
|
152
|
+
response_headers: dict[str, str] = {}
|
|
153
|
+
for k, v in resp.headers.items():
|
|
154
|
+
if k.lower() not in _HOP_BY_HOP:
|
|
155
|
+
response_headers[k.lower()] = v
|
|
156
|
+
|
|
157
|
+
# Always set Content-Length from the actual body we received.
|
|
158
|
+
response_headers["content-length"] = str(len(resp.content))
|
|
159
|
+
response_headers["connection"] = "close"
|
|
160
|
+
|
|
161
|
+
for k, v in response_headers.items():
|
|
162
|
+
lines.append(f"{k}: {v}")
|
|
163
|
+
|
|
164
|
+
header_section = "\r\n".join(lines) + "\r\n\r\n"
|
|
165
|
+
await stream.write(header_section.encode("latin-1") + resp.content)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _write_error(stream: "YamuxStream", code: int, message: str) -> None:
|
|
169
|
+
"""Write a minimal HTTP error response into *stream*."""
|
|
170
|
+
body = (message + "\n").encode()
|
|
171
|
+
reasons = {502: "Bad Gateway", 503: "Service Unavailable"}
|
|
172
|
+
reason = reasons.get(code, "Error")
|
|
173
|
+
response = (
|
|
174
|
+
f"HTTP/1.1 {code} {reason}\r\n"
|
|
175
|
+
f"Content-Type: text/plain\r\n"
|
|
176
|
+
f"Content-Length: {len(body)}\r\n"
|
|
177
|
+
f"Connection: close\r\n"
|
|
178
|
+
f"\r\n"
|
|
179
|
+
).encode() + body
|
|
180
|
+
try:
|
|
181
|
+
await stream.write(response)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal asyncio implementation of the hashicorp/yamux client protocol.
|
|
3
|
+
|
|
4
|
+
The Python library always plays the yamux *Client* role (even stream IDs).
|
|
5
|
+
The Go personaltunnel server plays the yamux *Server* role and opens streams
|
|
6
|
+
(odd stream IDs) — one per incoming public HTTP request.
|
|
7
|
+
|
|
8
|
+
Frame header (12 bytes, big-endian):
|
|
9
|
+
version (1 byte): always 0
|
|
10
|
+
type (1 byte): 0=Data, 1=WindowUpdate, 2=Ping, 3=GoAway
|
|
11
|
+
flags (2 bytes): SYN=0x0001, ACK=0x0002, FIN=0x0004, RST=0x0008
|
|
12
|
+
stream_id (4 bytes)
|
|
13
|
+
length (4 bytes): payload bytes (Data) or window delta (WindowUpdate)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import struct
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .crypto import EncConn
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Frame types
|
|
29
|
+
_TYPE_DATA = 0
|
|
30
|
+
_TYPE_WINDOW_UPDATE = 1
|
|
31
|
+
_TYPE_PING = 2
|
|
32
|
+
_TYPE_GO_AWAY = 3
|
|
33
|
+
|
|
34
|
+
# Frame flags
|
|
35
|
+
_FLAG_SYN = 0x0001
|
|
36
|
+
_FLAG_ACK = 0x0002
|
|
37
|
+
_FLAG_FIN = 0x0004
|
|
38
|
+
_FLAG_RST = 0x0008
|
|
39
|
+
|
|
40
|
+
# hashicorp/yamux default initial receive window
|
|
41
|
+
_INITIAL_WINDOW = 256 * 1024 # 262144 bytes
|
|
42
|
+
|
|
43
|
+
_HEADER_FMT = ">BBHII" # version, type, flags, stream_id, length
|
|
44
|
+
_HEADER_SIZE = 12
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pack_header(type_: int, flags: int, stream_id: int, length: int) -> bytes:
|
|
48
|
+
return struct.pack(_HEADER_FMT, 0, type_, flags, stream_id, length)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class YamuxStream:
|
|
52
|
+
"""
|
|
53
|
+
A single bidirectional yamux stream.
|
|
54
|
+
|
|
55
|
+
Callers use read() / write() / close() as with any async byte stream.
|
|
56
|
+
Created internally by YamuxSession when the server opens a new stream.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, stream_id: int, session: "YamuxSession") -> None:
|
|
60
|
+
self._stream_id = stream_id
|
|
61
|
+
self._session = session
|
|
62
|
+
|
|
63
|
+
self._recv_buf = bytearray()
|
|
64
|
+
self._data_event = asyncio.Event()
|
|
65
|
+
|
|
66
|
+
# Flow control: how much data the server is allowed to send us.
|
|
67
|
+
# The reader sends WindowUpdate frames to restore the server's quota.
|
|
68
|
+
self._send_window: int = _INITIAL_WINDOW
|
|
69
|
+
self._window_event = asyncio.Event()
|
|
70
|
+
|
|
71
|
+
self._eof = False # server sent FIN
|
|
72
|
+
self._rst = False # stream was reset
|
|
73
|
+
self._write_closed = False # we sent FIN
|
|
74
|
+
|
|
75
|
+
# ── Internal helpers called by YamuxSession._read_loop ──────────────────
|
|
76
|
+
|
|
77
|
+
def _feed(self, data: bytes, fin: bool) -> None:
|
|
78
|
+
"""Append incoming data and/or mark EOF."""
|
|
79
|
+
if data:
|
|
80
|
+
self._recv_buf.extend(data)
|
|
81
|
+
if fin:
|
|
82
|
+
self._eof = True
|
|
83
|
+
self._data_event.set()
|
|
84
|
+
|
|
85
|
+
def _reset(self) -> None:
|
|
86
|
+
"""Mark the stream as reset by the remote peer."""
|
|
87
|
+
self._rst = True
|
|
88
|
+
self._eof = True
|
|
89
|
+
self._data_event.set()
|
|
90
|
+
self._window_event.set()
|
|
91
|
+
|
|
92
|
+
def _add_send_window(self, delta: int) -> None:
|
|
93
|
+
"""Increase the send window and unblock any waiting write()."""
|
|
94
|
+
self._send_window += delta
|
|
95
|
+
if self._send_window > 0:
|
|
96
|
+
self._window_event.set()
|
|
97
|
+
|
|
98
|
+
# ── Public async API ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async def read(self, n: int) -> bytes:
|
|
101
|
+
"""
|
|
102
|
+
Read up to n bytes from the stream.
|
|
103
|
+
Blocks until data is available or the stream is closed (returns b'').
|
|
104
|
+
"""
|
|
105
|
+
while not self._recv_buf and not self._eof and not self._rst:
|
|
106
|
+
self._data_event.clear()
|
|
107
|
+
# Re-check after clearing to avoid a TOCTOU gap.
|
|
108
|
+
if self._recv_buf or self._eof or self._rst:
|
|
109
|
+
break
|
|
110
|
+
await self._data_event.wait()
|
|
111
|
+
|
|
112
|
+
if self._rst and not self._recv_buf:
|
|
113
|
+
raise ConnectionResetError("yamux stream was reset")
|
|
114
|
+
|
|
115
|
+
if not self._recv_buf:
|
|
116
|
+
return b"" # EOF
|
|
117
|
+
|
|
118
|
+
data = bytes(self._recv_buf[:n])
|
|
119
|
+
consumed = len(data)
|
|
120
|
+
del self._recv_buf[:consumed]
|
|
121
|
+
|
|
122
|
+
# Restore the server's send quota so it can keep sending.
|
|
123
|
+
if consumed > 0:
|
|
124
|
+
await self._session._send_frame(
|
|
125
|
+
_TYPE_WINDOW_UPDATE, 0, self._stream_id, consumed
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
async def write(self, data: bytes) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Write data to the stream.
|
|
133
|
+
Blocks when the remote receive window is exhausted (flow control).
|
|
134
|
+
"""
|
|
135
|
+
if self._write_closed:
|
|
136
|
+
raise ConnectionError("write on closed yamux stream")
|
|
137
|
+
|
|
138
|
+
offset = 0
|
|
139
|
+
while offset < len(data):
|
|
140
|
+
# Wait for window space.
|
|
141
|
+
while self._send_window <= 0 and not self._rst:
|
|
142
|
+
self._window_event.clear()
|
|
143
|
+
if self._send_window > 0 or self._rst:
|
|
144
|
+
break
|
|
145
|
+
await self._window_event.wait()
|
|
146
|
+
|
|
147
|
+
if self._rst:
|
|
148
|
+
raise ConnectionResetError("yamux stream was reset")
|
|
149
|
+
|
|
150
|
+
chunk_size = min(len(data) - offset, self._send_window)
|
|
151
|
+
chunk = data[offset : offset + chunk_size]
|
|
152
|
+
self._send_window -= chunk_size
|
|
153
|
+
|
|
154
|
+
await self._session._send_frame(
|
|
155
|
+
_TYPE_DATA, 0, self._stream_id, len(chunk), chunk
|
|
156
|
+
)
|
|
157
|
+
offset += chunk_size
|
|
158
|
+
|
|
159
|
+
async def close(self) -> None:
|
|
160
|
+
"""Send FIN to signal end-of-write; does not prevent further reads."""
|
|
161
|
+
if not self._write_closed and not self._rst:
|
|
162
|
+
self._write_closed = True
|
|
163
|
+
await self._session._send_frame(
|
|
164
|
+
_TYPE_DATA, _FLAG_FIN, self._stream_id, 0
|
|
165
|
+
)
|
|
166
|
+
self._session._remove_stream(self._stream_id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class YamuxSession:
|
|
170
|
+
"""
|
|
171
|
+
Async yamux client session.
|
|
172
|
+
|
|
173
|
+
After calling start(), the session accepts streams opened by the remote
|
|
174
|
+
yamux server (the Go personaltunnel server). Each accepted YamuxStream
|
|
175
|
+
carries exactly one HTTP request/response pair.
|
|
176
|
+
|
|
177
|
+
Usage::
|
|
178
|
+
|
|
179
|
+
session = YamuxSession(enc_conn)
|
|
180
|
+
await session.start()
|
|
181
|
+
|
|
182
|
+
while not session.closed:
|
|
183
|
+
stream = await session.accept()
|
|
184
|
+
if stream is None:
|
|
185
|
+
break
|
|
186
|
+
asyncio.create_task(handle_stream(stream))
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, enc_conn: "EncConn") -> None:
|
|
190
|
+
self._enc_conn = enc_conn
|
|
191
|
+
self._streams: dict[int, YamuxStream] = {}
|
|
192
|
+
self._accept_queue: asyncio.Queue[YamuxStream | None] = asyncio.Queue()
|
|
193
|
+
self._send_lock = asyncio.Lock()
|
|
194
|
+
self._reader_task: asyncio.Task | None = None
|
|
195
|
+
self._closed = False
|
|
196
|
+
|
|
197
|
+
async def start(self) -> None:
|
|
198
|
+
"""Launch the background frame-reader task."""
|
|
199
|
+
self._reader_task = asyncio.create_task(
|
|
200
|
+
self._read_loop(), name="yamux-reader"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
async def accept(self) -> YamuxStream | None:
|
|
204
|
+
"""
|
|
205
|
+
Wait for the server to open a new stream.
|
|
206
|
+
Returns None when the session has been closed.
|
|
207
|
+
"""
|
|
208
|
+
return await self._accept_queue.get()
|
|
209
|
+
|
|
210
|
+
async def close(self) -> None:
|
|
211
|
+
"""Close the session and its underlying connection."""
|
|
212
|
+
if not self._closed:
|
|
213
|
+
self._closed = True
|
|
214
|
+
if self._reader_task:
|
|
215
|
+
self._reader_task.cancel()
|
|
216
|
+
self._enc_conn.close()
|
|
217
|
+
await self._accept_queue.put(None)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def closed(self) -> bool:
|
|
221
|
+
return self._closed
|
|
222
|
+
|
|
223
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
async def _send_frame(
|
|
226
|
+
self,
|
|
227
|
+
type_: int,
|
|
228
|
+
flags: int,
|
|
229
|
+
stream_id: int,
|
|
230
|
+
length: int,
|
|
231
|
+
data: bytes = b"",
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Serialise and send a yamux frame atomically."""
|
|
234
|
+
async with self._send_lock:
|
|
235
|
+
header = _pack_header(type_, flags, stream_id, length)
|
|
236
|
+
await self._enc_conn.write(header + data)
|
|
237
|
+
|
|
238
|
+
def _remove_stream(self, stream_id: int) -> None:
|
|
239
|
+
self._streams.pop(stream_id, None)
|
|
240
|
+
|
|
241
|
+
async def _read_loop(self) -> None:
|
|
242
|
+
"""Background task: read yamux frames and dispatch to streams."""
|
|
243
|
+
try:
|
|
244
|
+
while not self._closed:
|
|
245
|
+
header = await self._enc_conn.read(_HEADER_SIZE)
|
|
246
|
+
_version, type_, flags, stream_id, length = struct.unpack(
|
|
247
|
+
_HEADER_FMT, header
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if type_ == _TYPE_DATA:
|
|
251
|
+
await self._handle_data(stream_id, flags, length)
|
|
252
|
+
elif type_ == _TYPE_WINDOW_UPDATE:
|
|
253
|
+
self._handle_window_update(stream_id, flags, length)
|
|
254
|
+
elif type_ == _TYPE_PING:
|
|
255
|
+
await self._handle_ping(flags, length)
|
|
256
|
+
elif type_ == _TYPE_GO_AWAY:
|
|
257
|
+
logger.info("yamux: received GoAway (code=%d)", length)
|
|
258
|
+
break
|
|
259
|
+
else:
|
|
260
|
+
logger.warning("yamux: unknown frame type %d, ignoring", type_)
|
|
261
|
+
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
pass
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
if not self._closed:
|
|
266
|
+
logger.debug("yamux: reader error: %r", exc)
|
|
267
|
+
finally:
|
|
268
|
+
self._closed = True
|
|
269
|
+
for stream in list(self._streams.values()):
|
|
270
|
+
stream._reset()
|
|
271
|
+
self._streams.clear()
|
|
272
|
+
await self._accept_queue.put(None)
|
|
273
|
+
|
|
274
|
+
async def _handle_data(self, stream_id: int, flags: int, length: int) -> None:
|
|
275
|
+
body = b""
|
|
276
|
+
if length > 0:
|
|
277
|
+
body = await self._enc_conn.read(length)
|
|
278
|
+
|
|
279
|
+
syn = bool(flags & _FLAG_SYN)
|
|
280
|
+
fin = bool(flags & _FLAG_FIN)
|
|
281
|
+
rst = bool(flags & _FLAG_RST)
|
|
282
|
+
|
|
283
|
+
if rst:
|
|
284
|
+
stream = self._streams.pop(stream_id, None)
|
|
285
|
+
if stream:
|
|
286
|
+
stream._reset()
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if syn:
|
|
290
|
+
# Server is opening a new stream.
|
|
291
|
+
stream = YamuxStream(stream_id, self)
|
|
292
|
+
self._streams[stream_id] = stream
|
|
293
|
+
# Acknowledge with WindowUpdate + ACK and grant our receive window.
|
|
294
|
+
await self._send_frame(
|
|
295
|
+
_TYPE_WINDOW_UPDATE, _FLAG_ACK, stream_id, _INITIAL_WINDOW
|
|
296
|
+
)
|
|
297
|
+
if body or fin:
|
|
298
|
+
stream._feed(body, fin)
|
|
299
|
+
await self._accept_queue.put(stream)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
stream = self._streams.get(stream_id)
|
|
303
|
+
if stream is None:
|
|
304
|
+
logger.debug(
|
|
305
|
+
"yamux: data for unknown stream %d (len=%d)", stream_id, length
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
stream._feed(body, fin)
|
|
310
|
+
|
|
311
|
+
def _handle_window_update(self, stream_id: int, flags: int, delta: int) -> None:
|
|
312
|
+
stream = self._streams.get(stream_id)
|
|
313
|
+
if stream is None:
|
|
314
|
+
return
|
|
315
|
+
stream._add_send_window(delta)
|
|
316
|
+
|
|
317
|
+
async def _handle_ping(self, flags: int, value: int) -> None:
|
|
318
|
+
if flags & _FLAG_SYN:
|
|
319
|
+
await self._send_frame(_TYPE_PING, _FLAG_ACK, 0, value)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: personaltunnel-python-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python asyncio client library for the personaltunnel Go server
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: bip-utils>=2.9.0
|
|
7
|
+
Requires-Dist: cffi<2.0
|
|
8
|
+
Requires-Dist: cryptography>=42.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
personaltunnel_client/__init__.py,sha256=izDLa5Mq6Nc_xvPZd1M3SwHRWEneHtsuddERwQncwEk,896
|
|
2
|
+
personaltunnel_client/client.py,sha256=vE1wHZzxPLdjeNaSYbz2RJkwl_WmM2SmZWcKQgms85k,8659
|
|
3
|
+
personaltunnel_client/crypto.py,sha256=O1Mgm4jTiiMQwM8zGkBM0p4mjBnL_H5d4_0w5vaNumA,8690
|
|
4
|
+
personaltunnel_client/forward.py,sha256=Rw6KU_ly_vImweoK-EC_sF_KvRULOChTGppL_7mCZQw,5558
|
|
5
|
+
personaltunnel_client/yamux.py,sha256=wG5E4gqMJIcvhLlcvrxju70HWPYdqAKKfhEEpmjhTFo,10859
|
|
6
|
+
personaltunnel_python_client-0.1.0.dist-info/METADATA,sha256=c9cnmQv3RRtEsDuF0_fcq56SUxZiEpO-zMUePuusY8s,289
|
|
7
|
+
personaltunnel_python_client-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
personaltunnel_python_client-0.1.0.dist-info/RECORD,,
|