FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel
This commit is contained in:
+112
-10
@@ -1,7 +1,16 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
||||
headers: Dict[str, str] = {}
|
||||
body: Dict[str, Any] = {}
|
||||
|
||||
import json
|
||||
|
||||
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
]
|
||||
# Private ranges: only reachable when the target host is one the user has
|
||||
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||
_PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # ULA
|
||||
]
|
||||
|
||||
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||
|
||||
|
||||
def _configured_ai_hosts(user_id: str) -> set:
|
||||
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||
hosts = set()
|
||||
configs = _load_ai_configs()
|
||||
providers = configs.get(user_id) or _get_default_providers()
|
||||
for p in providers:
|
||||
base = (p.get("api_base_url") or "").strip()
|
||||
if not base:
|
||||
continue
|
||||
try:
|
||||
host = urlparse(base).hostname
|
||||
if host:
|
||||
hosts.add(host.lower())
|
||||
except Exception:
|
||||
continue
|
||||
return hosts
|
||||
|
||||
|
||||
async def _resolve_host_ips(hostname: str):
|
||||
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||
ips = []
|
||||
for info in infos:
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(info[4][0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ips
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _validate_target_url(url: str, user_id: str):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||
|
||||
allowed_hosts = _configured_ai_hosts(user_id)
|
||||
|
||||
# Hostname-level fast path for loopback hosts
|
||||
if hostname in _LOOPBACK_HOSTS:
|
||||
if hostname in allowed_hosts:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
# Try direct IP parse (hostname may itself be an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
ips = [ip]
|
||||
except ValueError:
|
||||
ips = await _resolve_host_ips(hostname)
|
||||
|
||||
if not ips:
|
||||
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||
|
||||
for ip in ips:
|
||||
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||
if hostname in allowed_hosts:
|
||||
continue
|
||||
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
|
||||
@router.post("/proxy")
|
||||
async def proxy_llm(req: ProxyRequest):
|
||||
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||
await _validate_target_url(req.url, current_user["user_id"])
|
||||
# Never forward the app's own auth token upstream.
|
||||
headers = {
|
||||
k: v for k, v in req.headers.items()
|
||||
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
req.url,
|
||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
||||
json=req.body
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||
raw = resp.text
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||
raise HTTPException(status_code=502, detail=msg)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user