mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 统一 SSL 证书配置并支持用户创建时设置访问权限
- 新增 ssl_utils.py 模块,统一使用 certifi 证书 - 将所有 httpx 客户端的 verify 参数改为使用 get_ssl_context() - 管理员创建用户时支持设置 allowed_providers/api_formats/models - 修复 provider_ops 敏感字段列表缺失 session_cookie
This commit is contained in:
@@ -14,6 +14,7 @@ from src.clients import get_redis_client
|
||||
from src.core.logger import logger
|
||||
from src.models.database import User
|
||||
from src.utils.auth_utils import require_admin
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -118,7 +119,7 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
||||
|
||||
# 从 models.dev 获取数据
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
|
||||
response = await client.get("https://models.dev/api.json")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -22,6 +22,7 @@ from src.services.model.upstream_fetcher import (
|
||||
fetch_models_from_endpoints,
|
||||
)
|
||||
from src.utils.auth_utils import get_current_user
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
|
||||
@@ -302,7 +303,7 @@ async def test_model(
|
||||
}
|
||||
|
||||
# 发送测试请求
|
||||
async with httpx.AsyncClient(timeout=endpoint_config["timeout"]) as client:
|
||||
async with httpx.AsyncClient(timeout=endpoint_config["timeout"], verify=get_ssl_context()) as client:
|
||||
# 非流式测试
|
||||
logger.debug(f"[test-model] 开始非流式测试...")
|
||||
|
||||
|
||||
@@ -227,6 +227,11 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
else:
|
||||
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
|
||||
|
||||
# 处理访问权限字段:空数组转为 None(表示无限制)
|
||||
allowed_providers = request.allowed_providers if request.allowed_providers else None
|
||||
allowed_api_formats = request.allowed_api_formats if request.allowed_api_formats else None
|
||||
allowed_models = request.allowed_models if request.allowed_models else None
|
||||
|
||||
try:
|
||||
user = UserService.create_user(
|
||||
db=db,
|
||||
@@ -235,6 +240,9 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
password=request.password,
|
||||
role=role,
|
||||
quota_usd=quota_usd,
|
||||
allowed_providers=allowed_providers,
|
||||
allowed_api_formats=allowed_api_formats,
|
||||
allowed_models=allowed_models,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestException(str(exc))
|
||||
|
||||
@@ -28,6 +28,7 @@ import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.core.headers import CORE_REDACT_HEADERS, merge_headers_with_protection, redact_headers_for_log
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
@@ -545,7 +546,7 @@ class HttpRequestExecutor:
|
||||
|
||||
try:
|
||||
# 使用httpx进行异步请求
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client:
|
||||
response = await client.post(
|
||||
url=request.url,
|
||||
json=request.json_body,
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.database.database import get_pool_status
|
||||
from src.models.database import Model, Provider
|
||||
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
|
||||
from src.services.provider.transport import build_provider_url
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
router = APIRouter(tags=["System Catalog"])
|
||||
|
||||
@@ -281,7 +282,7 @@ async def test_connection(
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
|
||||
resp = await client.post(url, json=provider_payload, headers=provider_headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@@ -19,6 +19,7 @@ import httpx
|
||||
|
||||
from src.config import config
|
||||
from src.core.logger import logger
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# 模块级锁,避免类属性延迟初始化的竞态条件
|
||||
_proxy_clients_lock = asyncio.Lock()
|
||||
@@ -130,7 +131,7 @@ class HTTPClientPool:
|
||||
if cls._default_client is None:
|
||||
cls._default_client = httpx.AsyncClient(
|
||||
http2=False, # 暂时禁用HTTP/2以提高兼容性
|
||||
verify=True, # 启用SSL验证
|
||||
verify=get_ssl_context(), # 使用 certifi 证书
|
||||
timeout=httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
@@ -163,7 +164,7 @@ class HTTPClientPool:
|
||||
if cls._default_client is None:
|
||||
cls._default_client = httpx.AsyncClient(
|
||||
http2=False, # 暂时禁用HTTP/2以提高兼容性
|
||||
verify=True, # 启用SSL验证
|
||||
verify=get_ssl_context(), # 使用 certifi 证书
|
||||
timeout=httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
@@ -200,7 +201,7 @@ class HTTPClientPool:
|
||||
# 合并默认配置和自定义配置
|
||||
default_config = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"verify": get_ssl_context(),
|
||||
"timeout": httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
@@ -281,7 +282,7 @@ class HTTPClientPool:
|
||||
# 创建新客户端(使用默认超时,请求时可覆盖)
|
||||
client_config: Dict[str, Any] = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"verify": get_ssl_context(),
|
||||
"follow_redirects": True,
|
||||
"limits": httpx.Limits(
|
||||
max_connections=config.http_max_connections,
|
||||
@@ -350,7 +351,7 @@ class HTTPClientPool:
|
||||
"""
|
||||
default_config = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"verify": get_ssl_context(),
|
||||
"timeout": httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
@@ -388,7 +389,7 @@ class HTTPClientPool:
|
||||
"""
|
||||
client_config: Dict[str, Any] = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"verify": get_ssl_context(),
|
||||
"follow_redirects": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,10 @@ class CreateUserRequest(BaseModel):
|
||||
role: Optional[UserRole] = Field(UserRole.USER, description="用户角色")
|
||||
quota_usd: Optional[float] = Field(default=None, description="USD配额,null表示使用系统默认配额")
|
||||
unlimited: bool = Field(default=False, description="是否无限配额")
|
||||
# 访问限制字段
|
||||
allowed_providers: Optional[List[str]] = Field(default=None, description="允许使用的提供商ID列表,null表示无限制")
|
||||
allowed_api_formats: Optional[List[str]] = Field(default=None, description="允许使用的API格式列表,null表示无限制")
|
||||
allowed_models: Optional[List[str]] = Field(default=None, description="允许使用的模型名称列表,null表示无限制")
|
||||
|
||||
@field_validator("quota_usd", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -7,6 +7,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
|
||||
import httpx
|
||||
|
||||
from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.database import OAuthProvider
|
||||
@@ -97,7 +98,7 @@ class OAuthProviderBase(ABC):
|
||||
timeout_seconds: float = 5.0,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context()) as client:
|
||||
return await client.post(url, data=data, headers=headers)
|
||||
|
||||
async def _http_get(
|
||||
@@ -107,5 +108,5 @@ class OAuthProviderBase(ABC):
|
||||
timeout_seconds: float = 5.0,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context()) as client:
|
||||
return await client.get(url, headers=headers)
|
||||
|
||||
@@ -26,6 +26,7 @@ from src.services.auth.service import AuthService
|
||||
from src.services.cache.user_cache import UserCacheService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.auth.oauth.base import OAuthProviderBase
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
class OAuthService:
|
||||
@@ -785,7 +786,7 @@ class OAuthService:
|
||||
|
||||
async def _reachable(url: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context()) as client:
|
||||
await client.get(url)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -800,7 +801,7 @@ class OAuthService:
|
||||
if cfg.client_secret_encrypted:
|
||||
# 使用无效 code 做一次 token 请求(仅做粗略判定)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=get_ssl_context()) as client:
|
||||
resp = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
@@ -860,7 +861,7 @@ class OAuthService:
|
||||
|
||||
async def _reachable(url: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context()) as client:
|
||||
await client.get(url)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -875,7 +876,7 @@ class OAuthService:
|
||||
if client_secret:
|
||||
# 使用无效 code 做一次 token 请求(仅做粗略判定)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=get_ssl_context()) as client:
|
||||
resp = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
@@ -19,23 +18,13 @@ else:
|
||||
AIOSMTPLIB_AVAILABLE = True
|
||||
aiosmtplib = _aiosmtplib
|
||||
|
||||
|
||||
def _create_ssl_context() -> ssl.SSLContext:
|
||||
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
|
||||
try:
|
||||
import certifi
|
||||
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
context = ssl.create_default_context()
|
||||
return context
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.utils.async_utils import run_in_executor
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
from .email_template import EmailTemplate
|
||||
|
||||
@@ -224,7 +213,7 @@ class EmailSenderService:
|
||||
message.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# 发送邮件
|
||||
ssl_context = _create_ssl_context()
|
||||
ssl_context = get_ssl_context()
|
||||
if config["smtp_use_ssl"]:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
@@ -324,7 +313,7 @@ class EmailSenderService:
|
||||
|
||||
# 连接 SMTP 服务器
|
||||
server: Optional[smtplib.SMTP] = None
|
||||
ssl_context = _create_ssl_context()
|
||||
ssl_context = get_ssl_context()
|
||||
try:
|
||||
if config["smtp_use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(
|
||||
@@ -392,7 +381,7 @@ class EmailSenderService:
|
||||
return False, error
|
||||
|
||||
try:
|
||||
ssl_context = _create_ssl_context()
|
||||
ssl_context = get_ssl_context()
|
||||
if AIOSMTPLIB_AVAILABLE:
|
||||
# 使用异步方式测试
|
||||
# 注意: use_tls=True 表示隐式 SSL (端口 465)
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.core.enums import APIFormat
|
||||
from src.core.headers import get_extra_headers_from_endpoint
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ProviderEndpoint
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# 并发请求限制
|
||||
MAX_CONCURRENT_REQUESTS = 5
|
||||
@@ -148,7 +149,7 @@ async def fetch_models_from_endpoints(
|
||||
logger.exception(f"获取 {api_format} 模型出错")
|
||||
return [], f"{api_format}: error", False
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, verify=get_ssl_context()) as client:
|
||||
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
|
||||
for models, error, success in results:
|
||||
all_models.extend(models)
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
from src.services.provider_ops.actions import AnyrouterBalanceAction, ProviderAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
@@ -193,7 +194,7 @@ async def _get_acw_cookie(base_url: str, timeout: float = 10) -> Optional[str]:
|
||||
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, verify=get_ssl_context()) as client:
|
||||
resp = await client.get(
|
||||
base_url,
|
||||
headers={
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional, Type
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
from src.services.provider_ops.types import (
|
||||
ConnectorAuthType,
|
||||
ConnectorState,
|
||||
@@ -125,6 +126,7 @@ class ProviderConnector(ABC):
|
||||
timeout=self._timeout,
|
||||
transport=transport,
|
||||
event_hooks={"request": [self._auth_hook]},
|
||||
verify=get_ssl_context(),
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.services.provider_ops.architectures.base import (
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _extract_cookies(cookie_string: str) -> Dict[str, str]:
|
||||
@@ -197,6 +198,7 @@ class YesCodeArchitecture(ProviderArchitecture):
|
||||
async with httpx.AsyncClient(
|
||||
headers={"Cookie": cookie_header},
|
||||
timeout=10.0,
|
||||
verify=get_ssl_context(),
|
||||
) as client:
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
extra_config["_combined_data"] = combined_data
|
||||
|
||||
@@ -617,7 +617,7 @@ class ProviderOpsService:
|
||||
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
|
||||
sensitive_fields = [
|
||||
"api_key", "password", "session_token", "cookie_string", "cookies",
|
||||
"token_cookie", "auth_cookie", # Cookie 认证字段
|
||||
"token_cookie", "auth_cookie", "session_cookie", # Cookie 认证字段
|
||||
]
|
||||
|
||||
for field in sensitive_fields:
|
||||
@@ -704,6 +704,8 @@ class ProviderOpsService:
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# 移除 base_url 末尾的斜杠
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
@@ -726,9 +728,14 @@ class ProviderOpsService:
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
|
||||
response = await client.get(verify_endpoint, headers=headers)
|
||||
|
||||
logger.debug(
|
||||
f"验证响应: status={response.status_code}, "
|
||||
f"content_type={response.headers.get('content-type')}"
|
||||
)
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
data = response.json()
|
||||
|
||||
@@ -31,6 +31,9 @@ class UserService:
|
||||
role: UserRole = UserRole.USER,
|
||||
quota_usd: Optional[float] = 10.0,
|
||||
email_verified: bool = False,
|
||||
allowed_providers: Optional[List[str]] = None,
|
||||
allowed_api_formats: Optional[List[str]] = None,
|
||||
allowed_models: Optional[List[str]] = None,
|
||||
) -> User:
|
||||
"""创建新用户,quota_usd 为 None 表示无限制,email 为 None 表示无邮箱"""
|
||||
|
||||
@@ -64,6 +67,9 @@ class UserService:
|
||||
role=role,
|
||||
quota_usd=quota_usd,
|
||||
is_active=True,
|
||||
allowed_providers=allowed_providers,
|
||||
allowed_api_formats=allowed_api_formats,
|
||||
allowed_models=allowed_models,
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
|
||||
26
src/utils/ssl_utils.py
Normal file
26
src/utils/ssl_utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
SSL 工具函数
|
||||
提供统一的 SSL 上下文创建功能
|
||||
"""
|
||||
|
||||
import ssl
|
||||
|
||||
try:
|
||||
import certifi
|
||||
|
||||
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
_SSL_CONTEXT = ssl.create_default_context()
|
||||
|
||||
|
||||
def get_ssl_context() -> ssl.SSLContext:
|
||||
"""
|
||||
获取 SSL 上下文
|
||||
|
||||
优先使用 certifi 证书包,如果未安装则使用系统默认证书。
|
||||
返回模块级缓存的 SSL 上下文实例。
|
||||
|
||||
Returns:
|
||||
ssl.SSLContext: SSL 上下文
|
||||
"""
|
||||
return _SSL_CONTEXT
|
||||
Reference in New Issue
Block a user