Create app.py
Browse files
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)
|