kite-strings 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.
- kite_strings-0.1.0/LICENSE +21 -0
- kite_strings-0.1.0/PKG-INFO +113 -0
- kite_strings-0.1.0/README.md +100 -0
- kite_strings-0.1.0/kite_string/__init__.py +3 -0
- kite_strings-0.1.0/kite_string/client.py +139 -0
- kite_strings-0.1.0/kite_string/server.py +227 -0
- kite_strings-0.1.0/kite_strings.egg-info/PKG-INFO +113 -0
- kite_strings-0.1.0/kite_strings.egg-info/SOURCES.txt +12 -0
- kite_strings-0.1.0/kite_strings.egg-info/dependency_links.txt +1 -0
- kite_strings-0.1.0/kite_strings.egg-info/entry_points.txt +3 -0
- kite_strings-0.1.0/kite_strings.egg-info/requires.txt +1 -0
- kite_strings-0.1.0/kite_strings.egg-info/top_level.txt +1 -0
- kite_strings-0.1.0/pyproject.toml +24 -0
- kite_strings-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fuqingxu
|
|
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,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kite-strings
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose web services on internal servers through a cloud relay with a dashboard and reverse proxy over WebSocket tunnels.
|
|
5
|
+
Author: fuqingxu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/binary-husky/kite-string
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: aiohttp>=3.9
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Kite - 反向隧道服务注册系统
|
|
15
|
+
|
|
16
|
+
将内网服务器上的 Web 服务通过云服务器暴露给外部访问。
|
|
17
|
+
|
|
18
|
+
## 适用场景
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
┌─────────┐ WebSocket ┌─────────────┐ HTTP ┌─────────┐
|
|
22
|
+
│ 内网服务器├──────────────►│ 云服务器 X │◄─────────│ 浏览器 │
|
|
23
|
+
│ A/B/C │ │ kite-server │ └─────────┘
|
|
24
|
+
│(kite-client) │ Dashboard │
|
|
25
|
+
└─────────┘ │ + 反向代理 │
|
|
26
|
+
└─────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- 内网服务器 A/B/C 无法从外部直接访问
|
|
30
|
+
- A/B/C 可以主动连接云服务器 X,反之不行
|
|
31
|
+
- 需要通过 X 访问 A/B/C 上的 Web 服务
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
Python 3.8+,唯一依赖 `aiohttp`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install aiohttp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 使用
|
|
42
|
+
|
|
43
|
+
### 1. 在云服务器 X 上启动 Server
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python kite_server.py --port 8080
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
可选参数:
|
|
50
|
+
|
|
51
|
+
| 参数 | 默认值 | 说明 |
|
|
52
|
+
|------|--------|------|
|
|
53
|
+
| `--host` | `0.0.0.0` | 监听地址 |
|
|
54
|
+
| `--port` | `8080` | 监听端口 |
|
|
55
|
+
|
|
56
|
+
### 2. 在内网服务器上启动 Client
|
|
57
|
+
|
|
58
|
+
假设 A 上有一个运行在 3000 端口的 Web 服务:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "my-api"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
可选参数:
|
|
65
|
+
|
|
66
|
+
| 参数 | 必填 | 默认值 | 说明 |
|
|
67
|
+
|------|------|--------|------|
|
|
68
|
+
| `--server` | 是 | - | Server 的 WebSocket 地址 |
|
|
69
|
+
| `--port` | 是 | - | 本地 Web 服务端口 |
|
|
70
|
+
| `--name` | 否 | `主机名:端口` | 服务显示名称 |
|
|
71
|
+
|
|
72
|
+
### 3. 访问
|
|
73
|
+
|
|
74
|
+
- **Dashboard**:浏览器打开 `http://X:8080`,查看所有已注册服务
|
|
75
|
+
- **代理访问**:点击服务名称,或直接访问 `http://X:8080/proxy/{service_id}/`
|
|
76
|
+
|
|
77
|
+
## 工作原理
|
|
78
|
+
|
|
79
|
+
1. Client 通过 WebSocket 连接 Server,注册本地服务信息
|
|
80
|
+
2. Server 记录服务并在 Dashboard 展示
|
|
81
|
+
3. 浏览器访问代理路径时,Server 将 HTTP 请求通过 WebSocket 转发给 Client
|
|
82
|
+
4. Client 向本地服务发起请求,将响应原路返回
|
|
83
|
+
5. Client 断线后自动重连(指数退避,1s → 30s)
|
|
84
|
+
|
|
85
|
+
## 示例
|
|
86
|
+
|
|
87
|
+
在同一台机器上快速体验:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# 终端 1:启动 Server
|
|
91
|
+
python kite_server.py --port 8080
|
|
92
|
+
|
|
93
|
+
# 终端 2:启动一个测试 Web 服务
|
|
94
|
+
python -m http.server 9000
|
|
95
|
+
|
|
96
|
+
# 终端 3:启动 Client,将 9000 端口暴露到 Server
|
|
97
|
+
python kite_client.py --server ws://localhost:8080/ws --port 9000 --name "file-server"
|
|
98
|
+
|
|
99
|
+
# 浏览器打开 http://localhost:8080 查看 Dashboard,点击服务即可访问
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 多服务注册
|
|
103
|
+
|
|
104
|
+
每个 Web 服务启动一个 Client 实例即可:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Server A
|
|
108
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "api-server"
|
|
109
|
+
python kite_client.py --server ws://X:8080/ws --port 8888 --name "jupyter"
|
|
110
|
+
|
|
111
|
+
# Server B
|
|
112
|
+
python kite_client.py --server ws://X:8080/ws --port 5000 --name "flask-app"
|
|
113
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Kite - 反向隧道服务注册系统
|
|
2
|
+
|
|
3
|
+
将内网服务器上的 Web 服务通过云服务器暴露给外部访问。
|
|
4
|
+
|
|
5
|
+
## 适用场景
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────┐ WebSocket ┌─────────────┐ HTTP ┌─────────┐
|
|
9
|
+
│ 内网服务器├──────────────►│ 云服务器 X │◄─────────│ 浏览器 │
|
|
10
|
+
│ A/B/C │ │ kite-server │ └─────────┘
|
|
11
|
+
│(kite-client) │ Dashboard │
|
|
12
|
+
└─────────┘ │ + 反向代理 │
|
|
13
|
+
└─────────────┘
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- 内网服务器 A/B/C 无法从外部直接访问
|
|
17
|
+
- A/B/C 可以主动连接云服务器 X,反之不行
|
|
18
|
+
- 需要通过 X 访问 A/B/C 上的 Web 服务
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
Python 3.8+,唯一依赖 `aiohttp`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install aiohttp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使用
|
|
29
|
+
|
|
30
|
+
### 1. 在云服务器 X 上启动 Server
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python kite_server.py --port 8080
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
可选参数:
|
|
37
|
+
|
|
38
|
+
| 参数 | 默认值 | 说明 |
|
|
39
|
+
|------|--------|------|
|
|
40
|
+
| `--host` | `0.0.0.0` | 监听地址 |
|
|
41
|
+
| `--port` | `8080` | 监听端口 |
|
|
42
|
+
|
|
43
|
+
### 2. 在内网服务器上启动 Client
|
|
44
|
+
|
|
45
|
+
假设 A 上有一个运行在 3000 端口的 Web 服务:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "my-api"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
可选参数:
|
|
52
|
+
|
|
53
|
+
| 参数 | 必填 | 默认值 | 说明 |
|
|
54
|
+
|------|------|--------|------|
|
|
55
|
+
| `--server` | 是 | - | Server 的 WebSocket 地址 |
|
|
56
|
+
| `--port` | 是 | - | 本地 Web 服务端口 |
|
|
57
|
+
| `--name` | 否 | `主机名:端口` | 服务显示名称 |
|
|
58
|
+
|
|
59
|
+
### 3. 访问
|
|
60
|
+
|
|
61
|
+
- **Dashboard**:浏览器打开 `http://X:8080`,查看所有已注册服务
|
|
62
|
+
- **代理访问**:点击服务名称,或直接访问 `http://X:8080/proxy/{service_id}/`
|
|
63
|
+
|
|
64
|
+
## 工作原理
|
|
65
|
+
|
|
66
|
+
1. Client 通过 WebSocket 连接 Server,注册本地服务信息
|
|
67
|
+
2. Server 记录服务并在 Dashboard 展示
|
|
68
|
+
3. 浏览器访问代理路径时,Server 将 HTTP 请求通过 WebSocket 转发给 Client
|
|
69
|
+
4. Client 向本地服务发起请求,将响应原路返回
|
|
70
|
+
5. Client 断线后自动重连(指数退避,1s → 30s)
|
|
71
|
+
|
|
72
|
+
## 示例
|
|
73
|
+
|
|
74
|
+
在同一台机器上快速体验:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 终端 1:启动 Server
|
|
78
|
+
python kite_server.py --port 8080
|
|
79
|
+
|
|
80
|
+
# 终端 2:启动一个测试 Web 服务
|
|
81
|
+
python -m http.server 9000
|
|
82
|
+
|
|
83
|
+
# 终端 3:启动 Client,将 9000 端口暴露到 Server
|
|
84
|
+
python kite_client.py --server ws://localhost:8080/ws --port 9000 --name "file-server"
|
|
85
|
+
|
|
86
|
+
# 浏览器打开 http://localhost:8080 查看 Dashboard,点击服务即可访问
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 多服务注册
|
|
90
|
+
|
|
91
|
+
每个 Web 服务启动一个 Client 实例即可:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Server A
|
|
95
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "api-server"
|
|
96
|
+
python kite_client.py --server ws://X:8080/ws --port 8888 --name "jupyter"
|
|
97
|
+
|
|
98
|
+
# Server B
|
|
99
|
+
python kite_client.py --server ws://X:8080/ws --port 5000 --name "flask-app"
|
|
100
|
+
```
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Kite Client - Register a local web service through a Kite server tunnel."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import platform
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Tunnel client
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
async def run_tunnel(server_url: str, local_port: int, name: str):
|
|
19
|
+
"""Connect to Kite server and serve proxy requests."""
|
|
20
|
+
backoff = 1
|
|
21
|
+
max_backoff = 30
|
|
22
|
+
host = platform.node()
|
|
23
|
+
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
async with aiohttp.ClientSession() as session:
|
|
27
|
+
print(f"Connecting to {server_url} ...")
|
|
28
|
+
async with session.ws_connect(server_url, heartbeat=20) as ws:
|
|
29
|
+
# Register
|
|
30
|
+
await ws.send_json({
|
|
31
|
+
"type": "register",
|
|
32
|
+
"name": name,
|
|
33
|
+
"port": local_port,
|
|
34
|
+
"host": host,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
reg = await ws.receive_json()
|
|
38
|
+
service_id = reg.get("id", "?")
|
|
39
|
+
print(f"Registered as '{name}' (id={service_id}), tunneling localhost:{local_port}")
|
|
40
|
+
backoff = 1 # reset on successful connect
|
|
41
|
+
|
|
42
|
+
async for msg in ws:
|
|
43
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
44
|
+
data = json.loads(msg.data)
|
|
45
|
+
if data.get("type") == "request":
|
|
46
|
+
asyncio.create_task(
|
|
47
|
+
handle_request(ws, data, local_port)
|
|
48
|
+
)
|
|
49
|
+
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
except (aiohttp.ClientError, OSError, asyncio.TimeoutError) as e:
|
|
53
|
+
print(f"Connection lost: {e}")
|
|
54
|
+
|
|
55
|
+
print(f"Reconnecting in {backoff}s ...")
|
|
56
|
+
await asyncio.sleep(backoff)
|
|
57
|
+
backoff = min(backoff * 2, max_backoff)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def handle_request(ws, data: dict, local_port: int):
|
|
61
|
+
"""Forward a proxied request to the local service and return the response."""
|
|
62
|
+
req_id = data["id"]
|
|
63
|
+
method = data.get("method", "GET")
|
|
64
|
+
path = data.get("path", "/")
|
|
65
|
+
query = data.get("query", "")
|
|
66
|
+
headers = data.get("headers", {})
|
|
67
|
+
body_b64 = data.get("body", "")
|
|
68
|
+
|
|
69
|
+
url = f"http://127.0.0.1:{local_port}{path}"
|
|
70
|
+
if query:
|
|
71
|
+
url += f"?{query}"
|
|
72
|
+
|
|
73
|
+
body = base64.b64decode(body_b64) if body_b64 else None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
async with aiohttp.ClientSession() as session:
|
|
77
|
+
async with session.request(
|
|
78
|
+
method, url, headers=headers, data=body, timeout=aiohttp.ClientTimeout(total=25)
|
|
79
|
+
) as resp:
|
|
80
|
+
resp_body = await resp.read()
|
|
81
|
+
resp_headers = {k: v for k, v in resp.headers.items()
|
|
82
|
+
if k.lower() not in ("transfer-encoding", "connection", "keep-alive")}
|
|
83
|
+
|
|
84
|
+
await ws.send_json({
|
|
85
|
+
"type": "response",
|
|
86
|
+
"id": req_id,
|
|
87
|
+
"status": resp.status,
|
|
88
|
+
"headers": resp_headers,
|
|
89
|
+
"body": base64.b64encode(resp_body).decode(),
|
|
90
|
+
})
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"Error proxying request {req_id}: {e}")
|
|
93
|
+
try:
|
|
94
|
+
await ws.send_json({
|
|
95
|
+
"type": "response",
|
|
96
|
+
"id": req_id,
|
|
97
|
+
"status": 502,
|
|
98
|
+
"headers": {"content-type": "text/plain"},
|
|
99
|
+
"body": base64.b64encode(f"Proxy error: {e}".encode()).decode(),
|
|
100
|
+
})
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Main
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
parser = argparse.ArgumentParser(description="Kite Client")
|
|
111
|
+
parser.add_argument("--server", required=True, help="Kite server WebSocket URL (e.g. ws://X:8080/ws)")
|
|
112
|
+
parser.add_argument("--port", type=int, required=True, help="Local port to expose")
|
|
113
|
+
parser.add_argument("--name", default="", help="Service name (default: hostname:port)")
|
|
114
|
+
args = parser.parse_args()
|
|
115
|
+
|
|
116
|
+
name = args.name or f"{platform.node()}:{args.port}"
|
|
117
|
+
|
|
118
|
+
loop = asyncio.new_event_loop()
|
|
119
|
+
|
|
120
|
+
def shutdown():
|
|
121
|
+
print("\nShutting down...")
|
|
122
|
+
for task in asyncio.all_tasks(loop):
|
|
123
|
+
task.cancel()
|
|
124
|
+
|
|
125
|
+
if sys.platform != "win32":
|
|
126
|
+
loop.add_signal_handler(signal.SIGINT, shutdown)
|
|
127
|
+
loop.add_signal_handler(signal.SIGTERM, shutdown)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
loop.run_until_complete(run_tunnel(args.server, args.port, name))
|
|
131
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
132
|
+
pass
|
|
133
|
+
finally:
|
|
134
|
+
loop.close()
|
|
135
|
+
print("Bye.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Kite Server - Reverse tunnel service registry and proxy."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from aiohttp import web, WSMsgType
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# State
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
services: dict[str, dict] = {} # service_id -> service info
|
|
18
|
+
pending: dict[str, asyncio.Future] = {} # request_id -> Future[response_msg]
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Dashboard HTML (embedded)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
DASHBOARD_HTML = """\
|
|
25
|
+
<!DOCTYPE html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<title>Kite Dashboard</title>
|
|
30
|
+
<style>
|
|
31
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
32
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
33
|
+
background: #f5f7fa; color: #333; padding: 2rem; }
|
|
34
|
+
h1 { margin-bottom: 1.5rem; font-size: 1.6rem; }
|
|
35
|
+
.empty { color: #999; font-style: italic; }
|
|
36
|
+
table { width: 100%; border-collapse: collapse; background: #fff;
|
|
37
|
+
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
|
38
|
+
th, td { text-align: left; padding: .75rem 1rem; }
|
|
39
|
+
th { background: #4a90d9; color: #fff; font-weight: 600; }
|
|
40
|
+
tr:nth-child(even) { background: #f9fafb; }
|
|
41
|
+
tr:hover { background: #eef3fb; }
|
|
42
|
+
a { color: #4a90d9; text-decoration: none; font-weight: 500; }
|
|
43
|
+
a:hover { text-decoration: underline; }
|
|
44
|
+
.status { display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
45
|
+
background: #4caf50; margin-right: .4rem; vertical-align: middle; }
|
|
46
|
+
.status.offline { background: #ccc; }
|
|
47
|
+
.refresh-note { margin-top: 1rem; font-size: .85rem; color: #999; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<h1>Kite Dashboard</h1>
|
|
52
|
+
<div id="content"><p class="empty">Loading...</p></div>
|
|
53
|
+
<p class="refresh-note">Auto-refreshes every 5 seconds</p>
|
|
54
|
+
<script>
|
|
55
|
+
async function load() {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch('/api/services');
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
if (data.length === 0) {
|
|
60
|
+
document.getElementById('content').innerHTML = '<p class="empty">No services registered.</p>';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let html = '<table><tr><th></th><th>Name</th><th>Host</th><th>Port</th><th>Registered</th></tr>';
|
|
64
|
+
for (const s of data) {
|
|
65
|
+
const t = new Date(s.registered_at * 1000).toLocaleString();
|
|
66
|
+
html += `<tr>
|
|
67
|
+
<td><span class="status ${s.online ? '' : 'offline'}"></span></td>
|
|
68
|
+
<td><a href="/proxy/${s.id}/" target="_blank">${esc(s.name)}</a></td>
|
|
69
|
+
<td>${esc(s.host)}</td>
|
|
70
|
+
<td>${s.port}</td>
|
|
71
|
+
<td>${t}</td>
|
|
72
|
+
</tr>`;
|
|
73
|
+
}
|
|
74
|
+
html += '</table>';
|
|
75
|
+
document.getElementById('content').innerHTML = html;
|
|
76
|
+
} catch(e) { console.error(e); }
|
|
77
|
+
}
|
|
78
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
79
|
+
load();
|
|
80
|
+
setInterval(load, 5000);
|
|
81
|
+
</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Handlers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def handle_dashboard(request: web.Request) -> web.Response:
|
|
91
|
+
return web.Response(text=DASHBOARD_HTML, content_type="text/html")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def handle_api_services(request: web.Request) -> web.Response:
|
|
95
|
+
items = sorted(services.values(), key=lambda s: s["registered_at"], reverse=True)
|
|
96
|
+
out = [
|
|
97
|
+
{
|
|
98
|
+
"id": s["id"],
|
|
99
|
+
"name": s["name"],
|
|
100
|
+
"host": s["host"],
|
|
101
|
+
"port": s["port"],
|
|
102
|
+
"registered_at": s["registered_at"],
|
|
103
|
+
"online": s.get("online", False),
|
|
104
|
+
}
|
|
105
|
+
for s in items
|
|
106
|
+
]
|
|
107
|
+
return web.json_response(out)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def handle_ws(request: web.Request) -> web.WebSocketResponse:
|
|
111
|
+
ws = web.WebSocketResponse(heartbeat=20)
|
|
112
|
+
await ws.prepare(request)
|
|
113
|
+
|
|
114
|
+
service_id = None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
async for msg in ws:
|
|
118
|
+
if msg.type == WSMsgType.TEXT:
|
|
119
|
+
data = json.loads(msg.data)
|
|
120
|
+
|
|
121
|
+
if data.get("type") == "register":
|
|
122
|
+
service_id = str(uuid.uuid4())[:8]
|
|
123
|
+
services[service_id] = {
|
|
124
|
+
"id": service_id,
|
|
125
|
+
"name": data.get("name", "unnamed"),
|
|
126
|
+
"host": data.get("host", "unknown"),
|
|
127
|
+
"port": data.get("port", 0),
|
|
128
|
+
"registered_at": time.time(),
|
|
129
|
+
"online": True,
|
|
130
|
+
"ws": ws,
|
|
131
|
+
}
|
|
132
|
+
await ws.send_json({"type": "registered", "id": service_id})
|
|
133
|
+
print(f"[+] Service registered: {services[service_id]['name']} "
|
|
134
|
+
f"({services[service_id]['host']}:{data.get('port')}) -> {service_id}")
|
|
135
|
+
|
|
136
|
+
elif data.get("type") == "response":
|
|
137
|
+
req_id = data.get("id")
|
|
138
|
+
fut = pending.pop(req_id, None)
|
|
139
|
+
if fut and not fut.done():
|
|
140
|
+
fut.set_result(data)
|
|
141
|
+
|
|
142
|
+
elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
|
|
143
|
+
break
|
|
144
|
+
finally:
|
|
145
|
+
if service_id and service_id in services:
|
|
146
|
+
services[service_id]["online"] = False
|
|
147
|
+
services[service_id].pop("ws", None)
|
|
148
|
+
print(f"[-] Service disconnected: {services[service_id]['name']} ({service_id})")
|
|
149
|
+
|
|
150
|
+
return ws
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def handle_proxy(request: web.Request) -> web.Response:
|
|
154
|
+
service_id = request.match_info["service_id"]
|
|
155
|
+
subpath = request.match_info.get("path", "")
|
|
156
|
+
|
|
157
|
+
svc = services.get(service_id)
|
|
158
|
+
if not svc or not svc.get("online") or not svc.get("ws"):
|
|
159
|
+
return web.Response(status=502, text="Service not available")
|
|
160
|
+
|
|
161
|
+
# Read request body
|
|
162
|
+
body_bytes = await request.read()
|
|
163
|
+
body_b64 = base64.b64encode(body_bytes).decode() if body_bytes else ""
|
|
164
|
+
|
|
165
|
+
# Build headers dict (single value per key for simplicity)
|
|
166
|
+
headers = {k: v for k, v in request.headers.items()
|
|
167
|
+
if k.lower() not in ("host", "connection", "upgrade")}
|
|
168
|
+
|
|
169
|
+
req_id = str(uuid.uuid4())
|
|
170
|
+
msg = {
|
|
171
|
+
"type": "request",
|
|
172
|
+
"id": req_id,
|
|
173
|
+
"method": request.method,
|
|
174
|
+
"path": "/" + subpath,
|
|
175
|
+
"query": request.query_string,
|
|
176
|
+
"headers": headers,
|
|
177
|
+
"body": body_b64,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
181
|
+
pending[req_id] = fut
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
ws = svc["ws"]
|
|
185
|
+
await ws.send_json(msg)
|
|
186
|
+
resp_data = await asyncio.wait_for(fut, timeout=30)
|
|
187
|
+
except (asyncio.TimeoutError, Exception) as e:
|
|
188
|
+
pending.pop(req_id, None)
|
|
189
|
+
return web.Response(status=504, text=f"Gateway timeout: {e}")
|
|
190
|
+
|
|
191
|
+
# Build response
|
|
192
|
+
status = resp_data.get("status", 502)
|
|
193
|
+
resp_headers = resp_data.get("headers", {})
|
|
194
|
+
resp_body = base64.b64decode(resp_data.get("body", "")) if resp_data.get("body") else b""
|
|
195
|
+
|
|
196
|
+
# Filter hop-by-hop headers
|
|
197
|
+
skip = {"transfer-encoding", "connection", "keep-alive"}
|
|
198
|
+
filtered = {k: v for k, v in resp_headers.items() if k.lower() not in skip}
|
|
199
|
+
|
|
200
|
+
return web.Response(status=status, headers=filtered, body=resp_body)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# App setup
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def create_app() -> web.Application:
|
|
208
|
+
app = web.Application()
|
|
209
|
+
app.router.add_get("/", handle_dashboard)
|
|
210
|
+
app.router.add_get("/api/services", handle_api_services)
|
|
211
|
+
app.router.add_get("/ws", handle_ws)
|
|
212
|
+
app.router.add_route("*", "/proxy/{service_id}/{path:.*}", handle_proxy)
|
|
213
|
+
return app
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
parser = argparse.ArgumentParser(description="Kite Server")
|
|
218
|
+
parser.add_argument("--host", default="0.0.0.0", help="Listen address (default: 0.0.0.0)")
|
|
219
|
+
parser.add_argument("--port", type=int, default=8080, help="Listen port (default: 8080)")
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
|
|
222
|
+
print(f"Kite server starting on {args.host}:{args.port}")
|
|
223
|
+
web.run_app(create_app(), host=args.host, port=args.port)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kite-strings
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose web services on internal servers through a cloud relay with a dashboard and reverse proxy over WebSocket tunnels.
|
|
5
|
+
Author: fuqingxu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/binary-husky/kite-string
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: aiohttp>=3.9
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Kite - 反向隧道服务注册系统
|
|
15
|
+
|
|
16
|
+
将内网服务器上的 Web 服务通过云服务器暴露给外部访问。
|
|
17
|
+
|
|
18
|
+
## 适用场景
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
┌─────────┐ WebSocket ┌─────────────┐ HTTP ┌─────────┐
|
|
22
|
+
│ 内网服务器├──────────────►│ 云服务器 X │◄─────────│ 浏览器 │
|
|
23
|
+
│ A/B/C │ │ kite-server │ └─────────┘
|
|
24
|
+
│(kite-client) │ Dashboard │
|
|
25
|
+
└─────────┘ │ + 反向代理 │
|
|
26
|
+
└─────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- 内网服务器 A/B/C 无法从外部直接访问
|
|
30
|
+
- A/B/C 可以主动连接云服务器 X,反之不行
|
|
31
|
+
- 需要通过 X 访问 A/B/C 上的 Web 服务
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
Python 3.8+,唯一依赖 `aiohttp`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install aiohttp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 使用
|
|
42
|
+
|
|
43
|
+
### 1. 在云服务器 X 上启动 Server
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python kite_server.py --port 8080
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
可选参数:
|
|
50
|
+
|
|
51
|
+
| 参数 | 默认值 | 说明 |
|
|
52
|
+
|------|--------|------|
|
|
53
|
+
| `--host` | `0.0.0.0` | 监听地址 |
|
|
54
|
+
| `--port` | `8080` | 监听端口 |
|
|
55
|
+
|
|
56
|
+
### 2. 在内网服务器上启动 Client
|
|
57
|
+
|
|
58
|
+
假设 A 上有一个运行在 3000 端口的 Web 服务:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "my-api"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
可选参数:
|
|
65
|
+
|
|
66
|
+
| 参数 | 必填 | 默认值 | 说明 |
|
|
67
|
+
|------|------|--------|------|
|
|
68
|
+
| `--server` | 是 | - | Server 的 WebSocket 地址 |
|
|
69
|
+
| `--port` | 是 | - | 本地 Web 服务端口 |
|
|
70
|
+
| `--name` | 否 | `主机名:端口` | 服务显示名称 |
|
|
71
|
+
|
|
72
|
+
### 3. 访问
|
|
73
|
+
|
|
74
|
+
- **Dashboard**:浏览器打开 `http://X:8080`,查看所有已注册服务
|
|
75
|
+
- **代理访问**:点击服务名称,或直接访问 `http://X:8080/proxy/{service_id}/`
|
|
76
|
+
|
|
77
|
+
## 工作原理
|
|
78
|
+
|
|
79
|
+
1. Client 通过 WebSocket 连接 Server,注册本地服务信息
|
|
80
|
+
2. Server 记录服务并在 Dashboard 展示
|
|
81
|
+
3. 浏览器访问代理路径时,Server 将 HTTP 请求通过 WebSocket 转发给 Client
|
|
82
|
+
4. Client 向本地服务发起请求,将响应原路返回
|
|
83
|
+
5. Client 断线后自动重连(指数退避,1s → 30s)
|
|
84
|
+
|
|
85
|
+
## 示例
|
|
86
|
+
|
|
87
|
+
在同一台机器上快速体验:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# 终端 1:启动 Server
|
|
91
|
+
python kite_server.py --port 8080
|
|
92
|
+
|
|
93
|
+
# 终端 2:启动一个测试 Web 服务
|
|
94
|
+
python -m http.server 9000
|
|
95
|
+
|
|
96
|
+
# 终端 3:启动 Client,将 9000 端口暴露到 Server
|
|
97
|
+
python kite_client.py --server ws://localhost:8080/ws --port 9000 --name "file-server"
|
|
98
|
+
|
|
99
|
+
# 浏览器打开 http://localhost:8080 查看 Dashboard,点击服务即可访问
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 多服务注册
|
|
103
|
+
|
|
104
|
+
每个 Web 服务启动一个 Client 实例即可:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Server A
|
|
108
|
+
python kite_client.py --server ws://X:8080/ws --port 3000 --name "api-server"
|
|
109
|
+
python kite_client.py --server ws://X:8080/ws --port 8888 --name "jupyter"
|
|
110
|
+
|
|
111
|
+
# Server B
|
|
112
|
+
python kite_client.py --server ws://X:8080/ws --port 5000 --name "flask-app"
|
|
113
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
kite_string/__init__.py
|
|
5
|
+
kite_string/client.py
|
|
6
|
+
kite_string/server.py
|
|
7
|
+
kite_strings.egg-info/PKG-INFO
|
|
8
|
+
kite_strings.egg-info/SOURCES.txt
|
|
9
|
+
kite_strings.egg-info/dependency_links.txt
|
|
10
|
+
kite_strings.egg-info/entry_points.txt
|
|
11
|
+
kite_strings.egg-info/requires.txt
|
|
12
|
+
kite_strings.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kite_string
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kite-strings"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Expose web services on internal servers through a cloud relay with a dashboard and reverse proxy over WebSocket tunnels."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "fuqingxu"},
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"aiohttp>=3.9",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
kite-server = "kite_string.server:main"
|
|
21
|
+
kite-client = "kite_string.client:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/binary-husky/kite-string"
|