buglupa-connect 0.1.0__tar.gz

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,59 @@
1
+ # Tests every push; publishes to PyPI only for a version tag (v0.1.0 and so on) whose version matches pyproject.toml.
2
+ # PyPI trusts this exact file (trusted publishing, no stored token), in the "pypi" environment.
3
+ name: Test and release
4
+
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ tags: ["v*"]
9
+ pull_request:
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ python: ["3.10", "3.12", "3.14"]
20
+ steps:
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
+ with:
23
+ persist-credentials: false
24
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
25
+ with:
26
+ python-version: ${{ matrix.python }}
27
+ - run: uv run --group dev pytest -q
28
+
29
+ build:
30
+ needs: test
31
+ if: startsWith(github.ref, 'refs/tags/v')
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35
+ with:
36
+ persist-credentials: false
37
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
38
+ - name: The tag matches the package version
39
+ run: |
40
+ version=$(uv version --short)
41
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME doesn't match version $version"; exit 1; }
42
+ - run: uv build
43
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+
48
+ publish:
49
+ needs: build
50
+ runs-on: ubuntu-latest
51
+ environment: pypi
52
+ permissions:
53
+ id-token: write
54
+ steps:
55
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
56
+ with:
57
+ name: dist
58
+ path: dist/
59
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yarqat (SMC-Private) Limited (https://yarqat.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.5
2
+ Name: buglupa-connect
3
+ Version: 0.1.0
4
+ Summary: buglupa connect: let BugLupa Cloud's scanner test an app on your computer through a private, outbound-only tunnel.
5
+ Project-URL: Homepage, https://buglupa.com
6
+ Project-URL: Documentation, https://buglupa.com/docs
7
+ Author-email: "Yarqat (SMC-Private) Limited" <support@yarqat.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # buglupa-connect
17
+
18
+ Let BugLupa Cloud's scanner test an app running on your computer, through a private tunnel that only BugLupa's
19
+ scanner can use. Your app is never put on the internet.
20
+
21
+ ```
22
+ $ pipx install buglupa-connect # or: uvx buglupa-connect localhost:3000
23
+ $ export BUGLUPA_CONNECT_TOKEN=... # a connect token for your project, from BugLupa Cloud
24
+ $ buglupa connect localhost:3000
25
+ ```
26
+
27
+ - The client makes one outbound, encrypted connection to BugLupa and never listens for connections.
28
+ - It forwards requests only to the one address you name (local and private addresses only).
29
+ - Every connection needs a short-lived token from your BugLupa Cloud project; Ctrl+C ends access at once.
30
+ - Python 3.10 or newer, standard library only, so there is nothing else to audit.
31
+
32
+ BugLupa Cloud is in early access at [buglupa.com](https://buglupa.com). Documentation: https://buglupa.com/docs
33
+
34
+ This client is open source under the MIT licence so you can read exactly what runs on your computer. BugLupa itself
35
+ is a separate product by [Yarqat](https://yarqat.com). Security reports: support@yarqat.com.
@@ -0,0 +1,20 @@
1
+ # buglupa-connect
2
+
3
+ Let BugLupa Cloud's scanner test an app running on your computer, through a private tunnel that only BugLupa's
4
+ scanner can use. Your app is never put on the internet.
5
+
6
+ ```
7
+ $ pipx install buglupa-connect # or: uvx buglupa-connect localhost:3000
8
+ $ export BUGLUPA_CONNECT_TOKEN=... # a connect token for your project, from BugLupa Cloud
9
+ $ buglupa connect localhost:3000
10
+ ```
11
+
12
+ - The client makes one outbound, encrypted connection to BugLupa and never listens for connections.
13
+ - It forwards requests only to the one address you name (local and private addresses only).
14
+ - Every connection needs a short-lived token from your BugLupa Cloud project; Ctrl+C ends access at once.
15
+ - Python 3.10 or newer, standard library only, so there is nothing else to audit.
16
+
17
+ BugLupa Cloud is in early access at [buglupa.com](https://buglupa.com). Documentation: https://buglupa.com/docs
18
+
19
+ This client is open source under the MIT licence so you can read exactly what runs on your computer. BugLupa itself
20
+ is a separate product by [Yarqat](https://yarqat.com). Security reports: support@yarqat.com.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "buglupa-connect"
3
+ version = "0.1.0"
4
+ description = "buglupa connect: let BugLupa Cloud's scanner test an app on your computer through a private, outbound-only tunnel."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Yarqat (SMC-Private) Limited", email = "support@yarqat.com" }]
10
+ # Standard library only: nothing to audit but this package, and a single-file build stays simple.
11
+ dependencies = []
12
+ classifiers = [
13
+ "Environment :: Console",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://buglupa.com"
20
+ Documentation = "https://buglupa.com/docs"
21
+
22
+ [project.scripts]
23
+ buglupa-connect = "buglupa_connect.cli:main"
24
+ buglupa = "buglupa_connect.cli:buglupa_main"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
28
+
29
+ [build-system]
30
+ requires = ["hatchling>=1.27"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/buglupa_connect"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ asyncio_mode = "auto"
39
+ asyncio_default_fixture_loop_scope = "function"
40
+
41
+ [tool.ruff]
42
+ line-length = 120
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I", "B", "S", "UP"]
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/**" = ["S101", "S105", "S106", "S107"]
@@ -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"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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