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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any