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 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...")
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """
2
+ atunnel: A minimal Python package to expose localhost to the public internet
3
+ using Cloudflare's cloudflared tunnel (quick tunnels).
4
+ """
5
+
6
+ from atunnel.tunnel import Tunnel
7
+
8
+ __all__ = ["Tunnel"]
9
+ __version__ = "1.0.0"
@@ -0,0 +1,9 @@
1
+ """Run atunnel with ``uv run atunnel``."""
2
+
3
+ import sys
4
+
5
+ from atunnel.cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ atunnel = atunnel.cli:main
@@ -0,0 +1 @@
1
+ atunnel