etunnel 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.
etunnel-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: etunnel
3
+ Version: 1.0.0
4
+ Summary: Expose a local service through an etunnel reverse-tunnel server with one line of code
5
+ Author: etunnel authors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ggcode/etunnel
8
+ Project-URL: Repository, https://github.com/ggcode/etunnel
9
+ Keywords: tunnel,reverse-proxy,webhook,localhost,ngrok
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: Proxy Servers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: websocket-client>=1.6
20
+
21
+ # etunnel for Python
22
+
23
+ websocket-client based.
24
+
25
+ Zero registration: every SDK client speaks the same tiny wire protocol
26
+ ([PROTOCOL.md](../../PROTOCOL.md)) against any etunnel server
27
+ (default `wss://t.ggcode.uk/tunnel`).
28
+
29
+ ## Install
30
+
31
+ ```
32
+ pip install etunnel
33
+ ```
34
+
35
+ Python >= 3.8 (websocket-client installed automatically).
36
+
37
+ ## CLI
38
+
39
+ ```
40
+ etunnel 3000
41
+ ```
42
+
43
+ Prints `TUNNEL_URL: https://<subdomain>.t.ggcode.uk` — that URL now routes to
44
+ your local service. Ctrl-C closes the tunnel.
45
+
46
+ ## Embed
47
+
48
+ ```python
49
+ from etunnel import connect
50
+ t = connect("wss://t.ggcode.uk/tunnel", 3000, {})
51
+ print(t["url"])
52
+ ```
53
+
54
+ ## Environment variables
55
+
56
+ | Variable | Meaning | Default |
57
+ |---|---|---|
58
+ | `ETUNNEL_SERVER` | tunnel server WebSocket URL | `wss://t.ggcode.uk/tunnel` |
59
+ | `ETUNNEL_LOCAL` | local port or `host:port` (or 1st CLI arg) | `3000` |
60
+ | `ETUNNEL_TOKEN` | paid-plan API key (optional) | - |
61
+ | `ETUNNEL_SUBDOMAIN` | custom subdomain (paid plans) | random |
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](../../LICENSE). Buffer-based protocol client: request and
66
+ response bodies are buffered per stream (fine for webhooks/dashboards/demos;
67
+ use the [Go SDK](../../sdk) for streaming and WebSocket pass-through).
@@ -0,0 +1,47 @@
1
+ # etunnel for Python
2
+
3
+ websocket-client based.
4
+
5
+ Zero registration: every SDK client speaks the same tiny wire protocol
6
+ ([PROTOCOL.md](../../PROTOCOL.md)) against any etunnel server
7
+ (default `wss://t.ggcode.uk/tunnel`).
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install etunnel
13
+ ```
14
+
15
+ Python >= 3.8 (websocket-client installed automatically).
16
+
17
+ ## CLI
18
+
19
+ ```
20
+ etunnel 3000
21
+ ```
22
+
23
+ Prints `TUNNEL_URL: https://<subdomain>.t.ggcode.uk` — that URL now routes to
24
+ your local service. Ctrl-C closes the tunnel.
25
+
26
+ ## Embed
27
+
28
+ ```python
29
+ from etunnel import connect
30
+ t = connect("wss://t.ggcode.uk/tunnel", 3000, {})
31
+ print(t["url"])
32
+ ```
33
+
34
+ ## Environment variables
35
+
36
+ | Variable | Meaning | Default |
37
+ |---|---|---|
38
+ | `ETUNNEL_SERVER` | tunnel server WebSocket URL | `wss://t.ggcode.uk/tunnel` |
39
+ | `ETUNNEL_LOCAL` | local port or `host:port` (or 1st CLI arg) | `3000` |
40
+ | `ETUNNEL_TOKEN` | paid-plan API key (optional) | - |
41
+ | `ETUNNEL_SUBDOMAIN` | custom subdomain (paid plans) | random |
42
+
43
+ ## License
44
+
45
+ MIT — see [LICENSE](../../LICENSE). Buffer-based protocol client: request and
46
+ response bodies are buffered per stream (fine for webhooks/dashboards/demos;
47
+ use the [Go SDK](../../sdk) for streaming and WebSocket pass-through).
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: etunnel
3
+ Version: 1.0.0
4
+ Summary: Expose a local service through an etunnel reverse-tunnel server with one line of code
5
+ Author: etunnel authors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ggcode/etunnel
8
+ Project-URL: Repository, https://github.com/ggcode/etunnel
9
+ Keywords: tunnel,reverse-proxy,webhook,localhost,ngrok
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: Proxy Servers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: websocket-client>=1.6
20
+
21
+ # etunnel for Python
22
+
23
+ websocket-client based.
24
+
25
+ Zero registration: every SDK client speaks the same tiny wire protocol
26
+ ([PROTOCOL.md](../../PROTOCOL.md)) against any etunnel server
27
+ (default `wss://t.ggcode.uk/tunnel`).
28
+
29
+ ## Install
30
+
31
+ ```
32
+ pip install etunnel
33
+ ```
34
+
35
+ Python >= 3.8 (websocket-client installed automatically).
36
+
37
+ ## CLI
38
+
39
+ ```
40
+ etunnel 3000
41
+ ```
42
+
43
+ Prints `TUNNEL_URL: https://<subdomain>.t.ggcode.uk` — that URL now routes to
44
+ your local service. Ctrl-C closes the tunnel.
45
+
46
+ ## Embed
47
+
48
+ ```python
49
+ from etunnel import connect
50
+ t = connect("wss://t.ggcode.uk/tunnel", 3000, {})
51
+ print(t["url"])
52
+ ```
53
+
54
+ ## Environment variables
55
+
56
+ | Variable | Meaning | Default |
57
+ |---|---|---|
58
+ | `ETUNNEL_SERVER` | tunnel server WebSocket URL | `wss://t.ggcode.uk/tunnel` |
59
+ | `ETUNNEL_LOCAL` | local port or `host:port` (or 1st CLI arg) | `3000` |
60
+ | `ETUNNEL_TOKEN` | paid-plan API key (optional) | - |
61
+ | `ETUNNEL_SUBDOMAIN` | custom subdomain (paid plans) | random |
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](../../LICENSE). Buffer-based protocol client: request and
66
+ response bodies are buffered per stream (fine for webhooks/dashboards/demos;
67
+ use the [Go SDK](../../sdk) for streaming and WebSocket pass-through).
@@ -0,0 +1,9 @@
1
+ README.md
2
+ etunnel.py
3
+ pyproject.toml
4
+ etunnel.egg-info/PKG-INFO
5
+ etunnel.egg-info/SOURCES.txt
6
+ etunnel.egg-info/dependency_links.txt
7
+ etunnel.egg-info/entry_points.txt
8
+ etunnel.egg-info/requires.txt
9
+ etunnel.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ etunnel = etunnel:_main
@@ -0,0 +1 @@
1
+ websocket-client>=1.6
@@ -0,0 +1 @@
1
+ etunnel
@@ -0,0 +1,184 @@
1
+ """etunnel SDK for Python (uses the `websocket-client` package).
2
+
3
+ Buffer-based protocol client: request/response bodies are buffered per stream
4
+ (WebSocket upgrades are not forwarded — use the Go SDK for those).
5
+
6
+ from etunnel import connect
7
+ t = connect("wss://t.ggcode.uk/tunnel", 3000)
8
+ print(t["url"])
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import signal
16
+ import sys
17
+ import threading
18
+ import urllib.request
19
+ import urllib.error
20
+ from typing import Any, Optional
21
+
22
+ import websocket # pip install websocket-client
23
+
24
+ MAXP = 32768
25
+
26
+ HOP = {
27
+ "connection", "proxy-connection", "keep-alive", "proxy-authenticate",
28
+ "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
29
+ }
30
+
31
+ KIND_DATA, KIND_FIN, KIND_RST = 0x01, 0x02, 0x03
32
+
33
+
34
+ def _frame(kind: int, sid: int, payload: bytes = b"") -> bytes:
35
+ return bytes([kind]) + sid.to_bytes(4, "big") + len(payload).to_bytes(4, "big") + payload
36
+
37
+
38
+ def _parse_frame(buf: bytes):
39
+ kind = buf[0]
40
+ sid = int.from_bytes(buf[1:5], "big")
41
+ n = int.from_bytes(buf[5:9], "big")
42
+ return kind, sid, buf[9:9 + n]
43
+
44
+
45
+ def connect(server: str, local, opts: Optional[dict] = None) -> dict:
46
+ """Connect to an etunnel server exposing the local service.
47
+
48
+ local: port number or "host:port".
49
+ opts: {token, subdomain, client, on_close}.
50
+ Returns a dict {url, hostname, subdomain, plan, close()}.
51
+ """
52
+ opts = opts or {}
53
+ if isinstance(local, int):
54
+ local = f"127.0.0.1:{local}"
55
+ elif ":" not in str(local):
56
+ local = f"127.0.0.1:{local}"
57
+
58
+ ws = websocket.create_connection(server, subprotocols=["etunnel-v1"], timeout=15)
59
+ ws.send(json.dumps({
60
+ "type": "hello", "proto": 1,
61
+ "subdomain": opts.get("subdomain"),
62
+ "token": opts.get("token"),
63
+ "client": opts.get("client", "etunnel-python/1.0"),
64
+ }))
65
+
66
+ while True:
67
+ m = json.loads(ws.recv())
68
+ if m.get("type") == "welcome":
69
+ info = m
70
+ break
71
+ if m.get("type") == "error":
72
+ ws.close()
73
+ raise RuntimeError(f"etunnel: {m.get('code')}: {m.get('message', '')}")
74
+
75
+ state = {"closed": False}
76
+ streams: dict[int, dict] = {}
77
+ lock = threading.Lock()
78
+
79
+ def handle(req: dict, body: bytes, sid: int) -> None:
80
+ headers = {k: ", ".join(vs) for k, vs in (req.get("headers") or {}).items()
81
+ if k.lower() not in HOP}
82
+ method = req.get("method", "GET")
83
+ data = body if method not in ("GET", "HEAD") else None
84
+ r = urllib.request.Request(f"http://{local}{req.get('path', '/')}", data=data,
85
+ headers=headers, method=method)
86
+ try:
87
+ with urllib.request.urlopen(r, timeout=60) as resp:
88
+ status = resp.status
89
+ rh = {k: [v] for k, v in resp.getheaders() if k.lower() not in HOP}
90
+ payload = resp.read()
91
+ except urllib.error.HTTPError as e:
92
+ status = e.code
93
+ rh = {k: [v] for k, v in e.headers.items() if k.lower() not in HOP}
94
+ payload = e.read()
95
+ except Exception as e: # noqa: BLE001
96
+ with lock:
97
+ ws.send(json.dumps({"type": "res", "streamId": sid, "status": 502,
98
+ "headers": {"Content-Type": ["text/plain"]}}))
99
+ ws.send_binary(_frame(KIND_DATA, sid, f"etunnel: local service error: {e}\n".encode()))
100
+ ws.send_binary(_frame(KIND_FIN, sid))
101
+ return
102
+ with lock:
103
+ ws.send(json.dumps({"type": "res", "streamId": sid, "status": status, "headers": rh}))
104
+ for i in range(0, len(payload), MAXP):
105
+ ws.send_binary(_frame(KIND_DATA, sid, payload[i:i + MAXP]))
106
+ ws.send_binary(_frame(KIND_FIN, sid))
107
+
108
+ def reader() -> None:
109
+ try:
110
+ while True:
111
+ m = ws.recv()
112
+ if isinstance(m, str):
113
+ msg = json.loads(m)
114
+ t = msg.get("type")
115
+ if t == "req":
116
+ streams[msg["streamId"]] = {"req": msg, "body": []}
117
+ elif t == "ping":
118
+ with lock:
119
+ ws.send(json.dumps({"type": "pong", "ts": msg.get("ts", 0)}))
120
+ elif t == "bye":
121
+ state["closed"] = True
122
+ if opts.get("on_close"):
123
+ opts["on_close"](msg.get("reason", ""))
124
+ ws.close()
125
+ return
126
+ else:
127
+ kind, sid, payload = _parse_frame(m)
128
+ st = streams.get(sid)
129
+ if not st:
130
+ continue
131
+ if kind == KIND_DATA:
132
+ st["body"].append(payload)
133
+ elif kind == KIND_FIN:
134
+ streams.pop(sid, None)
135
+ body = b"".join(st["body"])
136
+ threading.Thread(target=handle, args=(st["req"], body, sid), daemon=True).start()
137
+ elif kind == KIND_RST:
138
+ streams.pop(sid, None)
139
+ except Exception:
140
+ state["closed"] = True
141
+ if opts.get("on_close"):
142
+ opts["on_close"]("read closed")
143
+
144
+ threading.Thread(target=reader, daemon=True).start()
145
+
146
+ def close(reason: str = "client closed") -> None:
147
+ if state["closed"]:
148
+ return
149
+ state["closed"] = True
150
+ try:
151
+ ws.send(json.dumps({"type": "bye", "reason": reason}))
152
+ except Exception:
153
+ pass
154
+ ws.close()
155
+ if opts.get("on_close"):
156
+ opts["on_close"](reason)
157
+
158
+ return {
159
+ "url": info["url"],
160
+ "hostname": info["hostname"],
161
+ "subdomain": info["subdomain"],
162
+ "plan": info.get("plan", ""),
163
+ "close": close,
164
+ }
165
+
166
+
167
+ def _main() -> None:
168
+ server = os.environ.get("ETUNNEL_SERVER", "wss://t.ggcode.uk/tunnel")
169
+ local = os.environ.get("ETUNNEL_LOCAL") or (sys.argv[1] if len(sys.argv) > 1 else "3000")
170
+ try:
171
+ t = connect(server, local, {
172
+ "token": os.environ.get("ETUNNEL_TOKEN"),
173
+ "subdomain": os.environ.get("ETUNNEL_SUBDOMAIN"),
174
+ })
175
+ except Exception as e: # noqa: BLE001
176
+ print(f"etunnel: {e}", file=sys.stderr)
177
+ sys.exit(1)
178
+ print("TUNNEL_URL:", t["url"], flush=True)
179
+ signal.signal(signal.SIGINT, lambda *_: (t["close"](), sys.exit(0)))
180
+ signal.pause()
181
+
182
+
183
+ if __name__ == "__main__":
184
+ _main()
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "etunnel"
7
+ version = "1.0.0"
8
+ description = "Expose a local service through an etunnel reverse-tunnel server with one line of code"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "etunnel authors" }]
13
+ keywords = ["tunnel", "reverse-proxy", "webhook", "localhost", "ngrok"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: Proxy Servers",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = ["websocket-client>=1.6"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ggcode/etunnel"
27
+ Repository = "https://github.com/ggcode/etunnel"
28
+
29
+ [project.scripts]
30
+ etunnel = "etunnel:_main"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["etunnel"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+