buglupa-connect 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.
- buglupa_connect/__init__.py +8 -0
- buglupa_connect/__main__.py +5 -0
- buglupa_connect/cli.py +200 -0
- buglupa_connect/client.py +376 -0
- buglupa_connect/forward.py +395 -0
- buglupa_connect/http1.py +387 -0
- buglupa_connect/protocol.py +306 -0
- buglupa_connect/ws.py +306 -0
- buglupa_connect-0.1.0.dist-info/METADATA +35 -0
- buglupa_connect-0.1.0.dist-info/RECORD +13 -0
- buglupa_connect-0.1.0.dist-info/WHEEL +4 -0
- buglupa_connect-0.1.0.dist-info/entry_points.txt +3 -0
- buglupa_connect-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""`buglupa connect`: let BugLupa Cloud's scanner test an app on your computer through a private tunnel.
|
|
2
|
+
|
|
3
|
+
The client opens one outbound, encrypted WebSocket to BugLupa's tunnel service and never listens for connections.
|
|
4
|
+
Requests arrive over that connection, and the client forwards each one only to the address you named (for example
|
|
5
|
+
`localhost:3000`). Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
buglupa_connect/cli.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""`buglupa connect localhost:3000` (also `buglupa-connect localhost:3000`).
|
|
2
|
+
|
|
3
|
+
The connect token comes from the BUGLUPA_CONNECT_TOKEN environment variable, a file (`--token-file`) or a hidden
|
|
4
|
+
prompt; never from the command line, where other users of the computer could see it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import getpass
|
|
14
|
+
import ipaddress
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .client import ClientConfig, Event, TunnelClient
|
|
24
|
+
from .forward import TargetError, parse_target, resolve_target
|
|
25
|
+
from .ws import split_url
|
|
26
|
+
|
|
27
|
+
DEFAULT_SERVER = "wss://tunnel.buglupa.com/v1/connect"
|
|
28
|
+
TOKEN_ENV = "BUGLUPA_CONNECT_TOKEN" # noqa: S105 - the variable's name, not a secret
|
|
29
|
+
SERVER_ENV = "BUGLUPA_CONNECT_SERVER"
|
|
30
|
+
TOKEN_PREFIX = "blct_" # noqa: S105 - the token format's prefix
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Output:
|
|
34
|
+
"""The terminal view: a short status, then one line per request."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, stream: TextIO, color: bool, quiet: bool = False) -> None:
|
|
37
|
+
self.stream = stream
|
|
38
|
+
self.color = color
|
|
39
|
+
self.quiet = quiet
|
|
40
|
+
encoding = (getattr(stream, "encoding", None) or "ascii").lower()
|
|
41
|
+
fancy = encoding.replace("-", "") in ("utf8", "utf16", "utf32")
|
|
42
|
+
self.ok, self.bad, self.warn = ("✓", "✗", "!") if fancy else ("OK", "X", "!")
|
|
43
|
+
|
|
44
|
+
def _paint(self, text: str, code: str) -> str:
|
|
45
|
+
return f"\x1b[{code}m{text}\x1b[0m" if self.color else text
|
|
46
|
+
|
|
47
|
+
def line(self, text: str = "") -> None:
|
|
48
|
+
with contextlib.suppress(OSError, ValueError):
|
|
49
|
+
print(text, file=self.stream, flush=True)
|
|
50
|
+
|
|
51
|
+
def event(self, event: Event, target: str) -> None:
|
|
52
|
+
if event.kind in ("connected", "reconnected"):
|
|
53
|
+
data = event.data
|
|
54
|
+
project = data.get("project") or "your project"
|
|
55
|
+
if event.kind == "connected":
|
|
56
|
+
self.line(f'{self._paint(self.ok, "32")} Connected to BugLupa as "{project}" (project token)')
|
|
57
|
+
self.line(" Only BugLupa's scanner can reach this. Press Ctrl+C to stop.")
|
|
58
|
+
expires = data.get("expires_at")
|
|
59
|
+
if isinstance(expires, (int, float)):
|
|
60
|
+
until = dt.datetime.fromtimestamp(expires).strftime("%H:%M")
|
|
61
|
+
self.line(self._paint(f" Forwarding to {target}. The token is valid until {until}.", "2"))
|
|
62
|
+
else:
|
|
63
|
+
self.line(f"{self._paint(self.ok, '32')} Reconnected to BugLupa")
|
|
64
|
+
elif event.kind == "request":
|
|
65
|
+
if not self.quiet:
|
|
66
|
+
self.request(event.data)
|
|
67
|
+
elif event.kind in ("retry", "lost", "warning", "local_down"):
|
|
68
|
+
self.line(f"{self._paint(self.warn, '33')} {event.message}")
|
|
69
|
+
elif event.kind == "final":
|
|
70
|
+
self.line(f"{self._paint(self.bad, '31')} {event.message}")
|
|
71
|
+
elif event.kind == "stopped":
|
|
72
|
+
self.line(event.message)
|
|
73
|
+
|
|
74
|
+
def request(self, data: dict[str, object]) -> None:
|
|
75
|
+
method = str(data.get("method", ""))[:7]
|
|
76
|
+
path = str(data.get("path", ""))
|
|
77
|
+
status = data.get("status")
|
|
78
|
+
ms = data.get("ms", 0)
|
|
79
|
+
shown = f"{status}" if status else "---"
|
|
80
|
+
if self.color and isinstance(status, int):
|
|
81
|
+
code = "32" if status < 300 else "36" if status < 400 else "33" if status < 500 else "31"
|
|
82
|
+
shown = self._paint(shown, code)
|
|
83
|
+
note = f" {data['note']}" if data.get("note") else ""
|
|
84
|
+
self.line(f" {method:<5}{path:<12} {shown:>3} {ms:>4} ms{note}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_token(token_file: str | None, stdin_tty: bool) -> str:
|
|
88
|
+
if token_file:
|
|
89
|
+
path = Path(token_file).expanduser()
|
|
90
|
+
try:
|
|
91
|
+
token = path.read_text(encoding="utf-8").strip()
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise SystemExit(f"buglupa connect: can't read the token file {path} ({exc.strerror})") from exc
|
|
94
|
+
elif os.environ.get(TOKEN_ENV, "").strip():
|
|
95
|
+
token = os.environ[TOKEN_ENV].strip()
|
|
96
|
+
elif stdin_tty:
|
|
97
|
+
token = getpass.getpass("Connect token (from BugLupa; hidden): ").strip()
|
|
98
|
+
else:
|
|
99
|
+
raise SystemExit(f"buglupa connect: set {TOKEN_ENV} or use --token-file (the token is never an argument)")
|
|
100
|
+
if not token.startswith(TOKEN_PREFIX) or len(token) < 40 or any(c.isspace() for c in token):
|
|
101
|
+
raise SystemExit("buglupa connect: that isn't a BugLupa connect token (it starts with blct_)")
|
|
102
|
+
return token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_server(url: str) -> str:
|
|
106
|
+
try:
|
|
107
|
+
tls, host, _, _ = split_url(url)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
raise SystemExit(f"buglupa connect: {exc}") from exc
|
|
110
|
+
if not tls:
|
|
111
|
+
try:
|
|
112
|
+
loopback = ipaddress.ip_address(host).is_loopback
|
|
113
|
+
except ValueError:
|
|
114
|
+
loopback = host == "localhost"
|
|
115
|
+
if not loopback:
|
|
116
|
+
raise SystemExit("buglupa connect: an unencrypted ws:// server is only allowed on this computer")
|
|
117
|
+
return url
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parser(prog: str) -> argparse.ArgumentParser:
|
|
121
|
+
p = argparse.ArgumentParser(
|
|
122
|
+
prog=prog,
|
|
123
|
+
description="Let BugLupa Cloud's scanner test an app on your computer through a private tunnel.",
|
|
124
|
+
epilog=f"The connect token comes from {TOKEN_ENV}, --token-file or a hidden prompt.",
|
|
125
|
+
)
|
|
126
|
+
p.add_argument("target", help="the app's address: 3000, localhost:3000, 127.0.0.1:8080 or https://localhost:8443")
|
|
127
|
+
p.add_argument("--token-file", help="read the connect token from this file")
|
|
128
|
+
p.add_argument("--server", help=f"tunnel server (default {DEFAULT_SERVER}; env {SERVER_ENV})")
|
|
129
|
+
p.add_argument(
|
|
130
|
+
"--insecure-local-tls",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="for an https:// local app with a self-signed certificate: don't verify its certificate",
|
|
133
|
+
)
|
|
134
|
+
p.add_argument("--max-streams", type=int, default=32, help="most requests forwarded at once (default 32)")
|
|
135
|
+
p.add_argument("--quiet", action="store_true", help="don't print a line per request")
|
|
136
|
+
p.add_argument("--no-color", action="store_true", help="plain output")
|
|
137
|
+
p.add_argument("--version", action="version", version=f"buglupa-connect {__version__}")
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run(argv: list[str] | None, prog: str) -> int:
|
|
142
|
+
args = parser(prog).parse_args(argv)
|
|
143
|
+
color = sys.stdout.isatty() and not args.no_color and not os.environ.get("NO_COLOR")
|
|
144
|
+
out = Output(sys.stdout, color, args.quiet)
|
|
145
|
+
try:
|
|
146
|
+
target = resolve_target(parse_target(args.target, args.insecure_local_tls))
|
|
147
|
+
except TargetError as exc:
|
|
148
|
+
out.line(f"{out.bad} {exc}")
|
|
149
|
+
return 2
|
|
150
|
+
if not 1 <= args.max_streams <= 256:
|
|
151
|
+
out.line(f"{out.bad} --max-streams must be between 1 and 256")
|
|
152
|
+
return 2
|
|
153
|
+
server = check_server(args.server or os.environ.get(SERVER_ENV) or DEFAULT_SERVER)
|
|
154
|
+
if server != DEFAULT_SERVER:
|
|
155
|
+
out.line(f"{out.warn} Using the tunnel server {urlsplit(server).netloc} (not BugLupa's default).")
|
|
156
|
+
token = read_token(args.token_file, sys.stdin.isatty())
|
|
157
|
+
config = ClientConfig(server=server, token=token, target=target, max_streams=args.max_streams)
|
|
158
|
+
return asyncio.run(_main(config, out))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _main(config: ClientConfig, out: Output) -> int:
|
|
162
|
+
client = TunnelClient(config, lambda event: out.event(event, config.target.display))
|
|
163
|
+
loop = asyncio.get_running_loop()
|
|
164
|
+
presses = 0
|
|
165
|
+
|
|
166
|
+
def interrupt() -> None:
|
|
167
|
+
nonlocal presses
|
|
168
|
+
presses += 1
|
|
169
|
+
if presses > 1:
|
|
170
|
+
os._exit(130)
|
|
171
|
+
client.stop()
|
|
172
|
+
|
|
173
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
174
|
+
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
|
175
|
+
loop.add_signal_handler(sig, interrupt)
|
|
176
|
+
try:
|
|
177
|
+
return await client.run()
|
|
178
|
+
except asyncio.CancelledError: # Ctrl+C where signal handlers aren't available (Windows)
|
|
179
|
+
client.stop()
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main(argv: list[str] | None = None) -> int:
|
|
184
|
+
"""`buglupa-connect TARGET`."""
|
|
185
|
+
try:
|
|
186
|
+
return run(argv, "buglupa-connect")
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
return 130
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def buglupa_main(argv: list[str] | None = None) -> int:
|
|
192
|
+
"""`buglupa connect TARGET`."""
|
|
193
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
194
|
+
if not args or args[0] != "connect":
|
|
195
|
+
print("usage: buglupa connect TARGET (this package only provides `buglupa connect`)", file=sys.stderr)
|
|
196
|
+
return 2
|
|
197
|
+
try:
|
|
198
|
+
return run(args[1:], "buglupa connect")
|
|
199
|
+
except KeyboardInterrupt:
|
|
200
|
+
return 130
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""The tunnel session: one outbound WebSocket to BugLupa's tunnel service, streams multiplexed over it.
|
|
2
|
+
|
|
3
|
+
- Authenticates with the connect token (an `Authorization` header, never in a URL) and a per-process session secret,
|
|
4
|
+
which lets this same process take its tunnel back after a network drop while a second client is refused.
|
|
5
|
+
- Opens no listening socket. The service opens streams; for each one the client connects to the local app and
|
|
6
|
+
forwards (forward.py). A stream the service didn't open is never accepted, and at most `max_streams` run at once.
|
|
7
|
+
- Reconnects after network drops with exponential backoff and jitter, until the token expires. Expired or revoked
|
|
8
|
+
tokens, a replaced session and protocol errors end the program.
|
|
9
|
+
- `stop()` (Ctrl+C) closes the local connections and the WebSocket at once: BugLupa can't reach the app any more.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import platform
|
|
18
|
+
import random
|
|
19
|
+
import secrets
|
|
20
|
+
import ssl
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
from .forward import Forwarder, ForwardLimits, LocalTarget, RequestLog
|
|
29
|
+
from .protocol import (
|
|
30
|
+
CLOSE_CONFLICT,
|
|
31
|
+
CLOSE_EXPIRED,
|
|
32
|
+
CLOSE_REPLACED,
|
|
33
|
+
CLOSE_REVOKED,
|
|
34
|
+
CLOSE_STOPPED,
|
|
35
|
+
CLOSE_VIOLATION,
|
|
36
|
+
CREDIT,
|
|
37
|
+
DATA,
|
|
38
|
+
END,
|
|
39
|
+
FINAL_CLOSE_CODES,
|
|
40
|
+
LOCAL_HEADER,
|
|
41
|
+
OPEN,
|
|
42
|
+
OPEN_OK,
|
|
43
|
+
RESET,
|
|
44
|
+
SESSION_HEADER,
|
|
45
|
+
SUBPROTOCOL,
|
|
46
|
+
WINDOW,
|
|
47
|
+
Limits,
|
|
48
|
+
MuxStream,
|
|
49
|
+
ProtocolError,
|
|
50
|
+
decode,
|
|
51
|
+
encode,
|
|
52
|
+
reason_text,
|
|
53
|
+
reset_frame,
|
|
54
|
+
)
|
|
55
|
+
from .ws import TEXT, ConnectionClosed, HandshakeRejected, WebSocket
|
|
56
|
+
|
|
57
|
+
PING_INTERVAL_S = 20.0
|
|
58
|
+
PING_TIMEOUT_S = 20.0
|
|
59
|
+
WELCOME_TIMEOUT_S = 15.0
|
|
60
|
+
BACKOFF_MAX_S = 30.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Final(Exception):
|
|
64
|
+
"""The session ended for good (exit code and message)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, code: int, message: str) -> None:
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.code = code
|
|
69
|
+
self.message = message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Event:
|
|
74
|
+
kind: str # connected, reconnected, lost, retry, request, local_down, stopped, warning
|
|
75
|
+
message: str = ""
|
|
76
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
EventFn = Callable[[Event], None]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ClientConfig:
|
|
84
|
+
server: str
|
|
85
|
+
token: str = field(repr=False)
|
|
86
|
+
target: LocalTarget
|
|
87
|
+
max_streams: int = 32
|
|
88
|
+
forward: ForwardLimits = field(default_factory=ForwardLimits)
|
|
89
|
+
ssl_context: ssl.SSLContext | None = None
|
|
90
|
+
connect_timeout_s: float = 15.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TunnelClient:
|
|
94
|
+
def __init__(self, config: ClientConfig, on_event: EventFn) -> None:
|
|
95
|
+
self.config = config
|
|
96
|
+
self.emit = on_event
|
|
97
|
+
self.session = secrets.token_urlsafe(32)
|
|
98
|
+
self.stopping = asyncio.Event()
|
|
99
|
+
self.ws: WebSocket | None = None
|
|
100
|
+
self.streams: dict[int, MuxStream] = {}
|
|
101
|
+
self.tasks: set[asyncio.Task[Any]] = set()
|
|
102
|
+
self.forwarders: set[Forwarder] = set()
|
|
103
|
+
self.last_id = 0
|
|
104
|
+
self.limits = Limits(max_streams=config.max_streams)
|
|
105
|
+
self.welcome: dict[str, Any] = {}
|
|
106
|
+
self.expires_at: float | None = None
|
|
107
|
+
self.connected_once = False
|
|
108
|
+
|
|
109
|
+
# Lifecycle ---------------------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def stop(self) -> None:
|
|
112
|
+
"""Ctrl+C: end access now. Local connections are cut first, then the WebSocket is closed."""
|
|
113
|
+
if self.stopping.is_set():
|
|
114
|
+
return
|
|
115
|
+
self.stopping.set()
|
|
116
|
+
for forwarder in list(self.forwarders):
|
|
117
|
+
forwarder.close_local()
|
|
118
|
+
for stream in list(self.streams.values()):
|
|
119
|
+
stream.abort("stopped")
|
|
120
|
+
if self.ws is not None:
|
|
121
|
+
task = asyncio.ensure_future(self._stop_ws(self.ws))
|
|
122
|
+
self.tasks.add(task)
|
|
123
|
+
task.add_done_callback(self.tasks.discard)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
async def _stop_ws(ws: WebSocket) -> None:
|
|
127
|
+
"""Tell the service first (it cuts access on this message), then close."""
|
|
128
|
+
with contextlib.suppress(ConnectionError, OSError, TimeoutError, RuntimeError):
|
|
129
|
+
await asyncio.wait_for(ws.send_text('{"type":"stop"}'), 0.3)
|
|
130
|
+
await ws.close(CLOSE_STOPPED, "stopped by the user", timeout=0.3)
|
|
131
|
+
|
|
132
|
+
async def run(self) -> int:
|
|
133
|
+
"""Connect and serve until stopped or a final error. Returns the exit code."""
|
|
134
|
+
attempt = 0
|
|
135
|
+
try:
|
|
136
|
+
while not self.stopping.is_set():
|
|
137
|
+
if self.expires_at is not None and time.time() >= self.expires_at:
|
|
138
|
+
raise Final(
|
|
139
|
+
3, "Your connect token has expired. Create a new one in BugLupa and run the command again."
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
ws = await self._connect()
|
|
143
|
+
except HandshakeRejected as exc:
|
|
144
|
+
self._handshake_final(exc)
|
|
145
|
+
delay = self._backoff(attempt)
|
|
146
|
+
attempt += 1
|
|
147
|
+
self.emit(
|
|
148
|
+
Event("retry", f"BugLupa is busy or restarting ({exc.status}); retrying in {delay:.0f} s")
|
|
149
|
+
)
|
|
150
|
+
await self._sleep(delay)
|
|
151
|
+
continue
|
|
152
|
+
except (OSError, TimeoutError, ConnectionError) as exc:
|
|
153
|
+
delay = self._backoff(attempt)
|
|
154
|
+
attempt += 1
|
|
155
|
+
what = "reach" if not self.connected_once else "reconnect to"
|
|
156
|
+
self.emit(Event("retry", f"Can't {what} BugLupa ({_why(exc)}); retrying in {delay:.0f} s"))
|
|
157
|
+
await self._sleep(delay)
|
|
158
|
+
continue
|
|
159
|
+
attempt = 0
|
|
160
|
+
code, reason = await self._serve(ws)
|
|
161
|
+
if self.stopping.is_set():
|
|
162
|
+
break
|
|
163
|
+
if code in FINAL_CLOSE_CODES:
|
|
164
|
+
raise Final(*_final_message(code, reason))
|
|
165
|
+
delay = self._backoff(0)
|
|
166
|
+
self.emit(Event("lost", f"Connection to BugLupa lost; reconnecting in {delay:.0f} s"))
|
|
167
|
+
await self._sleep(delay)
|
|
168
|
+
except Final as final:
|
|
169
|
+
self.emit(Event("final", final.message, {"code": final.code}))
|
|
170
|
+
return final.code
|
|
171
|
+
finally:
|
|
172
|
+
await self._shutdown()
|
|
173
|
+
self.emit(Event("stopped", f"Stopped. BugLupa can no longer reach {self.config.target.display}."))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
async def _sleep(self, seconds: float) -> None:
|
|
177
|
+
with contextlib.suppress(TimeoutError):
|
|
178
|
+
await asyncio.wait_for(self.stopping.wait(), seconds)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _backoff(attempt: int) -> float:
|
|
182
|
+
base = min(BACKOFF_MAX_S, 1.0 * (2**attempt))
|
|
183
|
+
return base * (0.75 + random.random() * 0.5) # noqa: S311 - jitter, not security
|
|
184
|
+
|
|
185
|
+
def _handshake_final(self, exc: HandshakeRejected) -> None:
|
|
186
|
+
message = str(exc.body.get("message") or "")
|
|
187
|
+
if exc.status == 401:
|
|
188
|
+
raise Final(2, message or "BugLupa didn't accept this connect token. Check that you copied all of it.")
|
|
189
|
+
if exc.status == 403:
|
|
190
|
+
raise Final(3, message or "This connect token has expired or was revoked. Create a new one in BugLupa.")
|
|
191
|
+
if exc.status == 409:
|
|
192
|
+
raise Final(4, message or "This project's tunnel is already connected from another computer or terminal.")
|
|
193
|
+
if exc.status in (400, 404, 426):
|
|
194
|
+
raise Final(2, message or f"BugLupa refused the connection (HTTP {exc.status}). Update buglupa-connect.")
|
|
195
|
+
|
|
196
|
+
async def _connect(self) -> WebSocket:
|
|
197
|
+
target = self.config.target
|
|
198
|
+
headers = {
|
|
199
|
+
"Authorization": f"Bearer {self.config.token}",
|
|
200
|
+
SESSION_HEADER: self.session,
|
|
201
|
+
LOCAL_HEADER: target.display,
|
|
202
|
+
"User-Agent": f"buglupa-connect/{__version__} (Python {platform.python_version()}; {sys.platform})",
|
|
203
|
+
}
|
|
204
|
+
ws = await WebSocket.connect(
|
|
205
|
+
self.config.server,
|
|
206
|
+
headers=headers,
|
|
207
|
+
subprotocol=SUBPROTOCOL,
|
|
208
|
+
ssl_context=self.config.ssl_context,
|
|
209
|
+
timeout=self.config.connect_timeout_s,
|
|
210
|
+
max_message=self.limits.max_frame + 64 * 1024,
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
kind, payload = await asyncio.wait_for(ws.recv(), WELCOME_TIMEOUT_S)
|
|
214
|
+
welcome = json.loads(payload) if kind == TEXT else {}
|
|
215
|
+
if not isinstance(welcome, dict) or welcome.get("type") != "welcome" or not welcome.get("tunnel"):
|
|
216
|
+
raise ConnectionError("BugLupa didn't send a welcome")
|
|
217
|
+
except BaseException:
|
|
218
|
+
ws.abort()
|
|
219
|
+
raise
|
|
220
|
+
limits = welcome.get("limits") or {}
|
|
221
|
+
self.limits = Limits(
|
|
222
|
+
max_frame=int(limits.get("max_frame", self.limits.max_frame)),
|
|
223
|
+
window=int(limits.get("window", self.limits.window)),
|
|
224
|
+
max_streams=min(self.config.max_streams, int(limits.get("max_streams", self.config.max_streams))),
|
|
225
|
+
)
|
|
226
|
+
self.welcome = welcome
|
|
227
|
+
expires = welcome.get("expires_at")
|
|
228
|
+
self.expires_at = float(expires) if isinstance(expires, (int, float)) else None
|
|
229
|
+
self.ws = ws
|
|
230
|
+
self.last_id = 0
|
|
231
|
+
self.emit(Event("reconnected" if self.connected_once else "connected", "", dict(welcome)))
|
|
232
|
+
self.connected_once = True
|
|
233
|
+
return ws
|
|
234
|
+
|
|
235
|
+
# Serving -----------------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async def _send(self, frame: bytes) -> None:
|
|
238
|
+
ws = self.ws
|
|
239
|
+
if ws is None:
|
|
240
|
+
raise ConnectionError("not connected")
|
|
241
|
+
await ws.send_binary(frame)
|
|
242
|
+
|
|
243
|
+
async def _serve(self, ws: WebSocket) -> tuple[int | None, str]:
|
|
244
|
+
pinger = asyncio.ensure_future(self._keepalive(ws))
|
|
245
|
+
try:
|
|
246
|
+
while True:
|
|
247
|
+
try:
|
|
248
|
+
kind, payload = await ws.recv()
|
|
249
|
+
except ConnectionClosed as closed:
|
|
250
|
+
return closed.code, closed.reason
|
|
251
|
+
if kind == TEXT:
|
|
252
|
+
self._control(payload)
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
await self._frame(payload)
|
|
256
|
+
except ProtocolError as exc:
|
|
257
|
+
await ws.close(CLOSE_VIOLATION, str(exc)[:100])
|
|
258
|
+
return CLOSE_VIOLATION, str(exc)
|
|
259
|
+
finally:
|
|
260
|
+
pinger.cancel()
|
|
261
|
+
for stream in list(self.streams.values()):
|
|
262
|
+
stream.abort("tunnel closed")
|
|
263
|
+
self.streams.clear()
|
|
264
|
+
for forwarder in list(self.forwarders):
|
|
265
|
+
forwarder.close_local()
|
|
266
|
+
self.ws = None
|
|
267
|
+
ws.abort()
|
|
268
|
+
|
|
269
|
+
async def _keepalive(self, ws: WebSocket) -> None:
|
|
270
|
+
with contextlib.suppress(ConnectionError, asyncio.CancelledError):
|
|
271
|
+
while True:
|
|
272
|
+
await asyncio.sleep(PING_INTERVAL_S)
|
|
273
|
+
if not await ws.ping(PING_TIMEOUT_S):
|
|
274
|
+
ws.abort() # the read loop sees the connection lost and reconnects
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
def _control(self, payload: bytes) -> None:
|
|
278
|
+
with contextlib.suppress(ValueError):
|
|
279
|
+
message = json.loads(payload)
|
|
280
|
+
if isinstance(message, dict) and message.get("type") == "notice":
|
|
281
|
+
self.emit(Event("warning", str(message.get("message", ""))[:300]))
|
|
282
|
+
|
|
283
|
+
async def _frame(self, message: bytes) -> None:
|
|
284
|
+
kind, stream_id, payload = decode(message, self.limits.max_frame)
|
|
285
|
+
if kind == OPEN:
|
|
286
|
+
if stream_id <= self.last_id:
|
|
287
|
+
raise ProtocolError("OPEN for a used stream id")
|
|
288
|
+
self.last_id = stream_id
|
|
289
|
+
if self.stopping.is_set() or len(self.streams) >= self.limits.max_streams:
|
|
290
|
+
await self._send(reset_frame(stream_id, "busy"))
|
|
291
|
+
return
|
|
292
|
+
stream = MuxStream(stream_id, self._send, self.limits)
|
|
293
|
+
self.streams[stream_id] = stream
|
|
294
|
+
self._spawn(self._open(stream))
|
|
295
|
+
return
|
|
296
|
+
stream = self.streams.get(stream_id)
|
|
297
|
+
if stream is None:
|
|
298
|
+
if stream_id > self.last_id:
|
|
299
|
+
raise ProtocolError("frame for a stream that was never opened")
|
|
300
|
+
return # a stream that just ended
|
|
301
|
+
if kind == DATA:
|
|
302
|
+
stream.on_data(payload)
|
|
303
|
+
elif kind == END:
|
|
304
|
+
stream.on_end()
|
|
305
|
+
elif kind == RESET:
|
|
306
|
+
stream.on_reset(reason_text(payload))
|
|
307
|
+
elif kind == WINDOW:
|
|
308
|
+
stream.on_window(CREDIT.unpack(payload)[0])
|
|
309
|
+
else:
|
|
310
|
+
raise ProtocolError("the service sent a client-only frame")
|
|
311
|
+
|
|
312
|
+
def _spawn(self, coro: Any) -> None:
|
|
313
|
+
task = asyncio.ensure_future(coro)
|
|
314
|
+
self.tasks.add(task)
|
|
315
|
+
task.add_done_callback(self.tasks.discard)
|
|
316
|
+
|
|
317
|
+
async def _open(self, stream: MuxStream) -> None:
|
|
318
|
+
forwarder = Forwarder(stream, self.config.target, str(self.welcome["tunnel"]), self.config.forward, self._log)
|
|
319
|
+
self.forwarders.add(forwarder)
|
|
320
|
+
try:
|
|
321
|
+
try:
|
|
322
|
+
await forwarder.connect()
|
|
323
|
+
except ConnectionError as exc:
|
|
324
|
+
self.emit(
|
|
325
|
+
Event("local_down", f"{self.config.target.display} isn't answering ({exc}). Is your app running?")
|
|
326
|
+
)
|
|
327
|
+
await stream.reset("local_unreachable")
|
|
328
|
+
return
|
|
329
|
+
try:
|
|
330
|
+
await self._send(encode(OPEN_OK, stream.id))
|
|
331
|
+
except ConnectionError:
|
|
332
|
+
return
|
|
333
|
+
await forwarder.run()
|
|
334
|
+
finally:
|
|
335
|
+
forwarder.close_local()
|
|
336
|
+
self.forwarders.discard(forwarder)
|
|
337
|
+
if self.streams.get(stream.id) is stream:
|
|
338
|
+
del self.streams[stream.id]
|
|
339
|
+
|
|
340
|
+
def _log(self, entry: RequestLog) -> None:
|
|
341
|
+
self.emit(Event("request", "", {"method": entry.method, "path": entry.path, "status": entry.status,
|
|
342
|
+
"ms": entry.ms, "note": entry.note})) # fmt: skip
|
|
343
|
+
|
|
344
|
+
async def _shutdown(self) -> None:
|
|
345
|
+
for stream in list(self.streams.values()):
|
|
346
|
+
stream.abort("stopped")
|
|
347
|
+
for forwarder in list(self.forwarders):
|
|
348
|
+
forwarder.close_local()
|
|
349
|
+
if self.ws is not None:
|
|
350
|
+
await self.ws.close(CLOSE_STOPPED, "stopped by the user", timeout=0.3)
|
|
351
|
+
for task in list(self.tasks):
|
|
352
|
+
task.cancel()
|
|
353
|
+
if self.tasks:
|
|
354
|
+
await asyncio.wait(list(self.tasks), timeout=0.5)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _why(exc: BaseException) -> str:
|
|
358
|
+
if isinstance(exc, TimeoutError):
|
|
359
|
+
return "timed out"
|
|
360
|
+
if isinstance(exc, ssl.SSLCertVerificationError):
|
|
361
|
+
return "its certificate couldn't be verified"
|
|
362
|
+
if isinstance(exc, OSError) and exc.strerror:
|
|
363
|
+
return exc.strerror
|
|
364
|
+
return type(exc).__name__
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _final_message(code: int | None, reason: str) -> tuple[int, str]:
|
|
368
|
+
if code == CLOSE_EXPIRED:
|
|
369
|
+
return 3, "Your connect token has expired, so BugLupa closed the tunnel. Create a new one and run again."
|
|
370
|
+
if code == CLOSE_REVOKED:
|
|
371
|
+
return 3, "Your connect token was revoked in BugLupa, so the tunnel is closed."
|
|
372
|
+
if code == CLOSE_REPLACED:
|
|
373
|
+
return 4, "This tunnel was taken over by a newer connection from this program; this one stops."
|
|
374
|
+
if code == CLOSE_CONFLICT:
|
|
375
|
+
return 4, "This project's tunnel is already connected from another computer or terminal."
|
|
376
|
+
return 1, f"BugLupa closed the tunnel because of a protocol error ({reason or code})."
|