yuebinliu commited on
Commit
40295ba
·
verified ·
1 Parent(s): 005419b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +247 -0
app.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException, Depends, status
2
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
3
+ from fastapi.responses import JSONResponse, StreamingResponse
4
+ import httpx
5
+ import yaml
6
+ import json
7
+ from typing import Dict, List, Optional
8
+ import urllib.parse
9
+ from pathlib import Path
10
+ import os
11
+ import secrets
12
+
13
+ app = FastAPI(title="Dynamic Multi-Site Reverse Proxy")
14
+
15
+ # 配置基本认证
16
+ security = HTTPBasic()
17
+
18
+ # 从环境变量获取认证用户名和密码
19
+ ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
20
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "default_password") # 请务必修改!
21
+
22
+ def verify_admin(credentials: HTTPBasicCredentials = Depends(security)):
23
+ """验证管理员身份"""
24
+ correct_username = secrets.compare_digest(credentials.username, ADMIN_USERNAME)
25
+ correct_password = secrets.compare_digest(credentials.password, ADMIN_PASSWORD)
26
+
27
+ if not (correct_username and correct_password):
28
+ raise HTTPException(
29
+ status_code=status.HTTP_401_UNAUTHORIZED,
30
+ detail="无效的认证信息",
31
+ headers={"WWW-Authenticate": "Basic"},
32
+ )
33
+ return credentials.username
34
+
35
+ # 内存中的站点配置
36
+ SITE_CONFIG = {}
37
+ DOMAIN_MAPPING = {}
38
+
39
+ async def load_initial_config():
40
+ """从环境变量或本地文件加载初始配置"""
41
+ global SITE_CONFIG, DOMAIN_MAPPING
42
+
43
+ # 方式1: 从环境变量读取配置 (JSON字符串)
44
+ config_json = os.getenv("PROXY_CONFIG")
45
+ if config_json:
46
+ try:
47
+ config = json.loads(config_json)
48
+ SITE_CONFIG = config.get("sites", {})
49
+ DOMAIN_MAPPING = config.get("domains", {})
50
+ print("从环境变量加载配置成功")
51
+ return
52
+ except json.JSONDecodeError as e:
53
+ print(f"环境变量配置JSON解析失败: {e}")
54
+
55
+ # 方式2: 从本地config.json文件加载
56
+ config_file = Path("config.json")
57
+ if config_file.exists():
58
+ try:
59
+ with open(config_file, 'r') as f:
60
+ config = json.load(f)
61
+ SITE_CONFIG = config.get("sites", {})
62
+ DOMAIN_MAPPING = config.get("domains", {})
63
+ print("从本地config.json加载配置成功")
64
+ except Exception as e:
65
+ print(f"加载本地配置文件失败: {e}")
66
+
67
+ # 方式3: 如果没有配置,使用示例配置
68
+ if not SITE_CONFIG:
69
+ SITE_CONFIG = {
70
+ "site1": {
71
+ "target": "http://106.15.4.153:8085",
72
+ "description": "主站点",
73
+ "enabled": True
74
+ }
75
+ }
76
+ DOMAIN_MAPPING = {
77
+ "www.example.com": "site1"
78
+ }
79
+ print("使用内置示例配置")
80
+
81
+ @app.on_event("startup")
82
+ async def startup_event():
83
+ """应用启动时加载配置"""
84
+ await load_initial_config()
85
+
86
+ # 可选: 从远程URL加载配置
87
+ remote_config_url = os.getenv("REMOTE_CONFIG_URL")
88
+ if remote_config_url:
89
+ try:
90
+ await load_remote_config(remote_config_url)
91
+ except Exception as e:
92
+ print(f"加载远程配置失败: {e}")
93
+
94
+ async def load_remote_config(config_url: str):
95
+ """从远程URL加载配置"""
96
+ try:
97
+ async with httpx.AsyncClient() as client:
98
+ response = await client.get(config_url, timeout=10.0)
99
+ if response.status_code == 200:
100
+ remote_config = response.json()
101
+ SITE_CONFIG.update(remote_config.get("sites", {}))
102
+ DOMAIN_MAPPING.update(remote_config.get("domains", {}))
103
+ print("从远程URL加载配置成功")
104
+ return True
105
+ except Exception as e:
106
+ print(f"加载远程配置失败: {e}")
107
+ return False
108
+
109
+ # 管理API端点
110
+ @app.get("/_config")
111
+ async def get_config(username: str = Depends(verify_admin)):
112
+ """获取当前配置(需要认证)"""
113
+ return {
114
+ "sites": SITE_CONFIG,
115
+ "domains": DOMAIN_MAPPING
116
+ }
117
+
118
+ @app.post("/_config/update")
119
+ async def update_config(
120
+ site_id: str,
121
+ target: str,
122
+ description: str = "",
123
+ username: str = Depends(verify_admin)
124
+ ):
125
+ """动态更新站点配置"""
126
+ SITE_CONFIG[site_id] = {
127
+ "target": target,
128
+ "description": description,
129
+ "enabled": True
130
+ }
131
+ return {"message": "配置更新成功", "site_id": site_id}
132
+
133
+ @app.delete("/_config/remove/{site_id}")
134
+ async def remove_config(site_id: str, username: str = Depends(verify_admin)):
135
+ """删除站点配置"""
136
+ if site_id in SITE_CONFIG:
137
+ del SITE_CONFIG[site_id]
138
+ # 同时移除相关的域名映射
139
+ global DOMAIN_MAPPING
140
+ DOMAIN_MAPPING = {k: v for k, v in DOMAIN_MAPPING.items() if v != site_id}
141
+ return {"message": f"站点 {site_id} 已删除"}
142
+ raise HTTPException(status_code=404, detail="站点不存在")
143
+
144
+ @app.post("/_config/domain")
145
+ async def add_domain_mapping(
146
+ domain: str,
147
+ site_id: str,
148
+ username: str = Depends(verify_admin)
149
+ ):
150
+ """添加域名映射"""
151
+ if site_id not in SITE_CONFIG:
152
+ raise HTTPException(status_code=404, detail="站点不存在")
153
+
154
+ DOMAIN_MAPPING[domain] = site_id
155
+ return {"message": f"域名 {domain} 已映射到站点 {site_id}"}
156
+
157
+ # 代理路由
158
+ @app.api_route("/{site_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"])
159
+ @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"])
160
+ async def proxy_request(
161
+ request: Request,
162
+ site_id: Optional[str] = None,
163
+ path: str = ""
164
+ ):
165
+ """多站点反向代理"""
166
+ try:
167
+ # 确定目标站点
168
+ host = request.headers.get("host", "").split(":")[0]
169
+ if host in DOMAIN_MAPPING:
170
+ site_id = DOMAIN_MAPPING[host]
171
+ elif not site_id:
172
+ site_id = request.query_params.get("__site")
173
+
174
+ if not site_id or site_id not in SITE_CONFIG:
175
+ site_id = next(iter(SITE_CONFIG.keys()), None) # 获取第一个站点作为默认
176
+
177
+ if not site_id:
178
+ raise HTTPException(status_code=404, detail="没有可用的代理站点")
179
+
180
+ site_config = SITE_CONFIG.get(site_id)
181
+ if not site_config or not site_config.get("enabled", True):
182
+ raise HTTPException(status_code=404, detail=f"站点 {site_id} 未找到或已禁用")
183
+
184
+ # 构建目标URL并转发请求
185
+ target_base = site_config["target"].rstrip("/")
186
+ target_url = f"{target_base}/{path}" if path else target_base
187
+
188
+ # 处理查询参数
189
+ query_params = dict(request.query_params)
190
+ query_params.pop("__site", None)
191
+ if query_params:
192
+ query_str = "&".join([f"{k}={urllib.parse.quote(v)}" for k, v in query_params.items()])
193
+ target_url += f"?{query_str}"
194
+
195
+ # 转发请求
196
+ async with httpx.AsyncClient(timeout=60.0) as client:
197
+ headers = dict(request.headers)
198
+ headers.pop("host", None)
199
+ headers.pop("content-length", None)
200
+
201
+ response = await client.request(
202
+ method=request.method,
203
+ url=target_url,
204
+ headers=headers,
205
+ content=await request.body(),
206
+ timeout=60.0,
207
+ follow_redirects=False
208
+ )
209
+
210
+ # 返回响应
211
+ response_headers = dict(response.headers)
212
+ response_headers.pop("content-length", None)
213
+
214
+ return StreamingResponse(
215
+ content=response.aiter_bytes(),
216
+ status_code=response.status_code,
217
+ headers=response_headers,
218
+ media_type=response.headers.get("content-type")
219
+ )
220
+
221
+ except httpx.RequestError as e:
222
+ raise HTTPException(status_code=502, detail=f"代理错误: {str(e)}")
223
+ except Exception as e:
224
+ raise HTTPException(status_code=500, detail=f"内部错误: {str(e)}")
225
+
226
+ @app.get("/")
227
+ async def root():
228
+ """显示使用说明"""
229
+ return {
230
+ "message": "多站点动态反向代理",
231
+ "usage": {
232
+ "通过域名访问": "不同的域名自动路由到不同站点",
233
+ "通过路径访问": "/site1/path 或 /site2/path",
234
+ "通过参数访问": "/any/path?__site=site1",
235
+ "管理配置": {
236
+ "查看配置": "GET /_config (需要认证)",
237
+ "更新配置": "POST /_config/update?site_id=xxx&target=xxx (需要认证)",
238
+ "删除配置": "DELETE /_config/remove/site_id (需要认证)",
239
+ "添加域名映射": "POST /_config/domain?domain=xxx&site_id=xxx (需要认证)"
240
+ },
241
+ "当前可用站点": list(SITE_CONFIG.keys())
242
+ }
243
+ }
244
+
245
+ if __name__ == "__main__":
246
+ import uvicorn
247
+ uvicorn.run(app, host="0.0.0.0", port=7860)