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 +67 -0
- etunnel-1.0.0/README.md +47 -0
- etunnel-1.0.0/etunnel.egg-info/PKG-INFO +67 -0
- etunnel-1.0.0/etunnel.egg-info/SOURCES.txt +9 -0
- etunnel-1.0.0/etunnel.egg-info/dependency_links.txt +1 -0
- etunnel-1.0.0/etunnel.egg-info/entry_points.txt +2 -0
- etunnel-1.0.0/etunnel.egg-info/requires.txt +1 -0
- etunnel-1.0.0/etunnel.egg-info/top_level.txt +1 -0
- etunnel-1.0.0/etunnel.py +184 -0
- etunnel-1.0.0/pyproject.toml +33 -0
- etunnel-1.0.0/setup.cfg +4 -0
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).
|
etunnel-1.0.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websocket-client>=1.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
etunnel
|
etunnel-1.0.0/etunnel.py
ADDED
|
@@ -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"]
|
etunnel-1.0.0/setup.cfg
ADDED