atunnel 1.0.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.
- atunnel-1.0.0/PKG-INFO +57 -0
- atunnel-1.0.0/README.md +37 -0
- atunnel-1.0.0/pyproject.toml +37 -0
- atunnel-1.0.0/setup.cfg +4 -0
- atunnel-1.0.0/src/atunnel/__init__.py +9 -0
- atunnel-1.0.0/src/atunnel/__main__.py +9 -0
- atunnel-1.0.0/src/atunnel/binary.py +135 -0
- atunnel-1.0.0/src/atunnel/cli.py +163 -0
- atunnel-1.0.0/src/atunnel/tunnel.py +109 -0
- atunnel-1.0.0/src/atunnel.egg-info/PKG-INFO +57 -0
- atunnel-1.0.0/src/atunnel.egg-info/SOURCES.txt +12 -0
- atunnel-1.0.0/src/atunnel.egg-info/dependency_links.txt +1 -0
- atunnel-1.0.0/src/atunnel.egg-info/entry_points.txt +2 -0
- atunnel-1.0.0/src/atunnel.egg-info/top_level.txt +1 -0
atunnel-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atunnel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A-tunnel is a tool to expose localhost to the public internet using Cloudflare Quick Tunnels
|
|
5
|
+
Author-email: Abodx9 <abodx@gmx.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/Abodx9/A-tunnel
|
|
8
|
+
Keywords: cloudflare,tunnel,localhost,expose,public
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# A-tunnel
|
|
22
|
+
A minimal Python package to expose localhost to the public internet using [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
|
|
23
|
+
|
|
24
|
+
No Cloudflare account required generates a temporary `*.trycloudflare.com` URL.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Using pip
|
|
30
|
+
pip install atunnel
|
|
31
|
+
|
|
32
|
+
# Or using uv
|
|
33
|
+
uv add atunnel
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
### CLI
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Expose local port 8080
|
|
42
|
+
atunnel --port 8080
|
|
43
|
+
|
|
44
|
+
# With uv
|
|
45
|
+
uv run atunnel --port 8080
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The public URL is printed to stdout. Press Ctrl+C to stop.
|
|
49
|
+
|
|
50
|
+
### Python API
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from atunnel.tunnel import Tunnel
|
|
54
|
+
|
|
55
|
+
with Tunnel(port=8080) as t:
|
|
56
|
+
print(f"Public URL: {t.public_url}")
|
|
57
|
+
input("Press Enter to stop...")
|
atunnel-1.0.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# A-tunnel
|
|
2
|
+
A minimal Python package to expose localhost to the public internet using [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
|
|
3
|
+
|
|
4
|
+
No Cloudflare account required generates a temporary `*.trycloudflare.com` URL.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# Using pip
|
|
10
|
+
pip install atunnel
|
|
11
|
+
|
|
12
|
+
# Or using uv
|
|
13
|
+
uv add atunnel
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### CLI
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Expose local port 8080
|
|
22
|
+
atunnel --port 8080
|
|
23
|
+
|
|
24
|
+
# With uv
|
|
25
|
+
uv run atunnel --port 8080
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The public URL is printed to stdout. Press Ctrl+C to stop.
|
|
29
|
+
|
|
30
|
+
### Python API
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from atunnel.tunnel import Tunnel
|
|
34
|
+
|
|
35
|
+
with Tunnel(port=8080) as t:
|
|
36
|
+
print(f"Public URL: {t.public_url}")
|
|
37
|
+
input("Press Enter to stop...")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atunnel"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A-tunnel is a tool to expose localhost to the public internet using Cloudflare Quick Tunnels"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Abodx9", email = "abodx@gmx.de"},
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
keywords = ["cloudflare", "tunnel", "localhost", "expose", "public"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Internet",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/Abodx9/A-tunnel"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
atunnel = "atunnel.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
include = ["atunnel*"]
|
atunnel-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Downloads and manages the cloudflared binary for the current platform.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_GITHUB_RELEASES_BASE = (
|
|
17
|
+
"https://github.com/cloudflare/cloudflared/releases/latest/download"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_BINARY_MAP = {
|
|
21
|
+
("Linux", "x86_64"): "cloudflared-linux-amd64",
|
|
22
|
+
("Linux", "aarch64"): "cloudflared-linux-arm64",
|
|
23
|
+
("Linux", "armv7l"): "cloudflared-linux-arm",
|
|
24
|
+
("Darwin", "x86_64"): "cloudflared-darwin-amd64.tgz",
|
|
25
|
+
("Darwin", "arm64"): "cloudflared-darwin-arm64.tgz",
|
|
26
|
+
("Windows", "AMD64"): "cloudflared-windows-amd64.exe",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cache_dir() -> Path:
|
|
31
|
+
"""Return the package-local directory for downloaded binaries."""
|
|
32
|
+
override = os.environ.get("ATUNNEL_BIN_DIR")
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override).expanduser()
|
|
35
|
+
|
|
36
|
+
return Path(__file__).parent / "bin"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _binary_name() -> str:
|
|
40
|
+
if platform.system() == "Windows":
|
|
41
|
+
return "cloudflared.exe"
|
|
42
|
+
return "cloudflared"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_binary() -> Optional[Path]:
|
|
46
|
+
"""Find an existing cloudflared binary (cache or PATH)."""
|
|
47
|
+
cached = _cache_dir() / _binary_name()
|
|
48
|
+
if cached.exists():
|
|
49
|
+
return cached
|
|
50
|
+
system_bin = shutil.which("cloudflared")
|
|
51
|
+
if system_bin:
|
|
52
|
+
return Path(system_bin)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_binary() -> Path:
|
|
57
|
+
"""Return path to cloudflared binary, downloading if necessary."""
|
|
58
|
+
existing = find_binary()
|
|
59
|
+
if existing:
|
|
60
|
+
return existing
|
|
61
|
+
return download()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def download() -> Path:
|
|
65
|
+
"""Download the cloudflared binary for the current platform."""
|
|
66
|
+
system = platform.system()
|
|
67
|
+
machine = platform.machine()
|
|
68
|
+
filename = _BINARY_MAP.get((system, machine))
|
|
69
|
+
|
|
70
|
+
if filename is None:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
f"Unsupported platform: {system} {machine}. "
|
|
73
|
+
f"Supported: {list(_BINARY_MAP.keys())}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
url = f"{_GITHUB_RELEASES_BASE}/{filename}"
|
|
77
|
+
cache = _cache_dir()
|
|
78
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
dest = cache / _binary_name()
|
|
80
|
+
|
|
81
|
+
print("Initializing for the first run...", file=sys.stderr)
|
|
82
|
+
|
|
83
|
+
if filename.endswith(".tgz"):
|
|
84
|
+
_download_tgz(url, dest)
|
|
85
|
+
else:
|
|
86
|
+
_download_file(url, dest)
|
|
87
|
+
|
|
88
|
+
if system != "Windows":
|
|
89
|
+
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
90
|
+
|
|
91
|
+
return dest
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _download_file(url: str, dest: Path) -> None:
|
|
95
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=dest.parent, suffix=".download")
|
|
96
|
+
try:
|
|
97
|
+
os.close(tmp_fd)
|
|
98
|
+
req = urllib.request.Request(url, headers={"User-Agent": "atunnel/1.0"})
|
|
99
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
100
|
+
with open(tmp_path, "wb") as f:
|
|
101
|
+
shutil.copyfileobj(resp, f)
|
|
102
|
+
shutil.move(tmp_path, dest)
|
|
103
|
+
except Exception:
|
|
104
|
+
try:
|
|
105
|
+
os.unlink(tmp_path)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _download_tgz(url: str, dest: Path) -> None:
|
|
112
|
+
import tarfile
|
|
113
|
+
|
|
114
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=dest.parent, suffix=".tgz")
|
|
115
|
+
try:
|
|
116
|
+
os.close(tmp_fd)
|
|
117
|
+
req = urllib.request.Request(url, headers={"User-Agent": "atunnel/1.0"})
|
|
118
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
119
|
+
with open(tmp_path, "wb") as f:
|
|
120
|
+
shutil.copyfileobj(resp, f)
|
|
121
|
+
|
|
122
|
+
with tarfile.open(tmp_path, "r:gz") as tar:
|
|
123
|
+
for member in tar.getnames():
|
|
124
|
+
if "cloudflared" in member and not member.endswith("/"):
|
|
125
|
+
extracted = tar.extractfile(member)
|
|
126
|
+
if extracted:
|
|
127
|
+
with open(dest, "wb") as f:
|
|
128
|
+
shutil.copyfileobj(extracted, f)
|
|
129
|
+
return
|
|
130
|
+
raise RuntimeError("cloudflared binary not found in archive")
|
|
131
|
+
finally:
|
|
132
|
+
try:
|
|
133
|
+
os.unlink(tmp_path)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for atunnel.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
atunnel --port 8080
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
from atunnel import __version__
|
|
16
|
+
from atunnel.tunnel import Tunnel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_RESET = "\033[0m"
|
|
20
|
+
_BOLD = "\033[1m"
|
|
21
|
+
_WHITE = "\033[97m"
|
|
22
|
+
_CYAN = "\033[36m"
|
|
23
|
+
_DIM = "\033[2m"
|
|
24
|
+
_GREEN = "\033[32m"
|
|
25
|
+
_YELLOW = "\033[33m"
|
|
26
|
+
_RED = "\033[31m"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _installed_version() -> str:
|
|
30
|
+
try:
|
|
31
|
+
return version("atunnel")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return __version__
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _color(text: str, *styles: str) -> str:
|
|
37
|
+
if not sys.stderr.isatty() or "NO_COLOR" in os.environ:
|
|
38
|
+
return text
|
|
39
|
+
return f"{''.join(styles)}{text}{_RESET}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_panel() -> None:
|
|
43
|
+
lines = [
|
|
44
|
+
"A-tunnel — Fast way to expose your apps to the internet",
|
|
45
|
+
" Made by Abodx",
|
|
46
|
+
]
|
|
47
|
+
width = max(len(line) for line in lines) + 2
|
|
48
|
+
border = _color("+" + "-" * width + "+", _CYAN)
|
|
49
|
+
print(border, file=sys.stderr)
|
|
50
|
+
print(
|
|
51
|
+
_color("| ", _CYAN)
|
|
52
|
+
+ _color(lines[0].ljust(width - 2), _BOLD, _WHITE)
|
|
53
|
+
+ _color(" |", _CYAN),
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
print(
|
|
57
|
+
_color("| ", _CYAN)
|
|
58
|
+
+ _color(lines[1].ljust(width - 2), _DIM)
|
|
59
|
+
+ _color(" |", _CYAN),
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
print(border, file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _local_server_exists(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
68
|
+
return True
|
|
69
|
+
except OSError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> int:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="atunnel",
|
|
76
|
+
description="Expose a local port to the internet via Cloudflare Quick Tunnels.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--port", type=int, required=True, help="Local port to expose"
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--host", type=str, default="localhost", help="Local host (default: localhost)"
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--protocol",
|
|
86
|
+
type=str,
|
|
87
|
+
choices=["http", "https"],
|
|
88
|
+
default="http",
|
|
89
|
+
help="Protocol (default: http)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--version",
|
|
93
|
+
action="version",
|
|
94
|
+
version=f"%(prog)s {_installed_version()}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
|
|
99
|
+
if not 1 <= args.port <= 65535:
|
|
100
|
+
parser.error("--port must be between 1 and 65535")
|
|
101
|
+
|
|
102
|
+
tunnel = Tunnel(port=args.port, host=args.host, protocol=args.protocol)
|
|
103
|
+
|
|
104
|
+
shutdown = False
|
|
105
|
+
|
|
106
|
+
def _handle_signal(signum, frame):
|
|
107
|
+
nonlocal shutdown
|
|
108
|
+
if shutdown:
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
shutdown = True
|
|
111
|
+
print("\nShutting down tunnel...", file=sys.stderr)
|
|
112
|
+
tunnel.stop()
|
|
113
|
+
|
|
114
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
115
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
_print_panel()
|
|
119
|
+
if not _local_server_exists(args.host, args.port):
|
|
120
|
+
print(
|
|
121
|
+
_color(
|
|
122
|
+
f"No localhost server or app was found on {args.host}:{args.port}.",
|
|
123
|
+
_RED,
|
|
124
|
+
_BOLD,
|
|
125
|
+
),
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
print(
|
|
131
|
+
_color(
|
|
132
|
+
f"Starting tunnel for {args.protocol}://{args.host}:{args.port}...",
|
|
133
|
+
_YELLOW,
|
|
134
|
+
),
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
url = tunnel.start()
|
|
138
|
+
|
|
139
|
+
print(_color("\nTunnel is live under:", _GREEN), file=sys.stderr)
|
|
140
|
+
print(url)
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
print(_color("Press Ctrl+C to stop.", _DIM), file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
# Block until tunnel exits or user interrupts
|
|
145
|
+
import time
|
|
146
|
+
|
|
147
|
+
while tunnel.is_running and not shutdown:
|
|
148
|
+
time.sleep(1)
|
|
149
|
+
|
|
150
|
+
if not shutdown and not tunnel.is_running:
|
|
151
|
+
print("Tunnel process exited unexpectedly.", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
except RuntimeError as e:
|
|
154
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
finally:
|
|
157
|
+
tunnel.stop()
|
|
158
|
+
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Starts a cloudflared quick tunnel subprocess and parses the public URL.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from atunnel.binary import ensure_binary
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_URL_PATTERN = re.compile(
|
|
16
|
+
r"https://[-a-zA-Z0-9@:%._\+~#=]{1,256}\.trycloudflare\.com"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Tunnel:
|
|
21
|
+
"""Manages a cloudflared quick tunnel subprocess."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, port: int, host: str = "localhost", protocol: str = "http") -> None:
|
|
24
|
+
self.port = port
|
|
25
|
+
self.host = host
|
|
26
|
+
self.protocol = protocol
|
|
27
|
+
self._process: Optional[subprocess.Popen] = None
|
|
28
|
+
self._public_url: Optional[str] = None
|
|
29
|
+
self._output_lines: list = []
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def public_url(self) -> Optional[str]:
|
|
34
|
+
return self._public_url
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_running(self) -> bool:
|
|
38
|
+
return self._process is not None and self._process.poll() is None
|
|
39
|
+
|
|
40
|
+
def start(self, timeout: float = 30.0) -> str:
|
|
41
|
+
"""Start the tunnel and return the public URL."""
|
|
42
|
+
binary = ensure_binary()
|
|
43
|
+
local_url = f"{self.protocol}://{self.host}:{self.port}"
|
|
44
|
+
cmd = [str(binary), "tunnel", "--url", local_url, "--no-autoupdate"]
|
|
45
|
+
|
|
46
|
+
self._process = subprocess.Popen(
|
|
47
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
reader = threading.Thread(target=self._read_stderr, daemon=True)
|
|
51
|
+
reader.start()
|
|
52
|
+
|
|
53
|
+
url = self._wait_for_url(timeout)
|
|
54
|
+
if url is None:
|
|
55
|
+
self.stop()
|
|
56
|
+
output = "\n".join(self._output_lines[-20:])
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"Failed to get tunnel URL within {timeout}s.\nOutput:\n{output}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self._public_url = url
|
|
62
|
+
return url
|
|
63
|
+
|
|
64
|
+
def stop(self) -> None:
|
|
65
|
+
"""Stop the tunnel process."""
|
|
66
|
+
if self._process is None:
|
|
67
|
+
return
|
|
68
|
+
if self._process.poll() is None:
|
|
69
|
+
self._process.terminate()
|
|
70
|
+
try:
|
|
71
|
+
self._process.wait(timeout=5)
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
self._process.kill()
|
|
74
|
+
self._process.wait(timeout=3)
|
|
75
|
+
self._process = None
|
|
76
|
+
self._public_url = None
|
|
77
|
+
|
|
78
|
+
def _read_stderr(self) -> None:
|
|
79
|
+
proc = self._process
|
|
80
|
+
if proc is None or proc.stderr is None:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
for line in proc.stderr:
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if line:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._output_lines.append(line)
|
|
88
|
+
except (ValueError, OSError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def _wait_for_url(self, timeout: float) -> Optional[str]:
|
|
92
|
+
deadline = time.monotonic() + timeout
|
|
93
|
+
while time.monotonic() < deadline:
|
|
94
|
+
if self._process and self._process.poll() is not None:
|
|
95
|
+
return None
|
|
96
|
+
with self._lock:
|
|
97
|
+
for line in self._output_lines:
|
|
98
|
+
match = _URL_PATTERN.search(line)
|
|
99
|
+
if match:
|
|
100
|
+
return match.group(0)
|
|
101
|
+
time.sleep(0.2)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def __enter__(self):
|
|
105
|
+
self.start()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *_):
|
|
109
|
+
self.stop()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atunnel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A-tunnel is a tool to expose localhost to the public internet using Cloudflare Quick Tunnels
|
|
5
|
+
Author-email: Abodx9 <abodx@gmx.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/Abodx9/A-tunnel
|
|
8
|
+
Keywords: cloudflare,tunnel,localhost,expose,public
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# A-tunnel
|
|
22
|
+
A minimal Python package to expose localhost to the public internet using [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
|
|
23
|
+
|
|
24
|
+
No Cloudflare account required generates a temporary `*.trycloudflare.com` URL.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Using pip
|
|
30
|
+
pip install atunnel
|
|
31
|
+
|
|
32
|
+
# Or using uv
|
|
33
|
+
uv add atunnel
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
### CLI
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Expose local port 8080
|
|
42
|
+
atunnel --port 8080
|
|
43
|
+
|
|
44
|
+
# With uv
|
|
45
|
+
uv run atunnel --port 8080
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The public URL is printed to stdout. Press Ctrl+C to stop.
|
|
49
|
+
|
|
50
|
+
### Python API
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from atunnel.tunnel import Tunnel
|
|
54
|
+
|
|
55
|
+
with Tunnel(port=8080) as t:
|
|
56
|
+
print(f"Public URL: {t.public_url}")
|
|
57
|
+
input("Press Enter to stop...")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/atunnel/__init__.py
|
|
4
|
+
src/atunnel/__main__.py
|
|
5
|
+
src/atunnel/binary.py
|
|
6
|
+
src/atunnel/cli.py
|
|
7
|
+
src/atunnel/tunnel.py
|
|
8
|
+
src/atunnel.egg-info/PKG-INFO
|
|
9
|
+
src/atunnel.egg-info/SOURCES.txt
|
|
10
|
+
src/atunnel.egg-info/dependency_links.txt
|
|
11
|
+
src/atunnel.egg-info/entry_points.txt
|
|
12
|
+
src/atunnel.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atunnel
|