diff --git a/.env b/.env index 7b6659b..eb5a7a2 100644 --- a/.env +++ b/.env @@ -136,9 +136,6 @@ CAPTCHA_NOISE_DOTS=30 # CORS允许的源 - 逗号分隔 CORS_ALLOWED_ORIGINS=http://localhost:8001 -# 可信代理层数 - 0=不信任X-Forwarded-For,>=1 表示在反向代理后 -TRUSTED_PROXY_COUNT=0 - # ============================================================ # 邮件配置 diff --git a/.env.example b/.env.example index fcbdff1..b6911fb 100644 --- a/.env.example +++ b/.env.example @@ -129,10 +129,6 @@ CAPTCHA_NOISE_DOTS=30 # CORS允许的源 - 逗号分隔,如 http://localhost:8001,https://admin.example.com CORS_ALLOWED_ORIGINS=http://localhost:8001 -# 可信代理层数 - 0=不信任X-Forwarded-For;仅部署在Nginx等反代后才设>=1 -TRUSTED_PROXY_COUNT=0 -# 可信代理IP白名单(逗号分隔) -TRUSTED_PROXY_IPS= # ============================================================ # 邮件配置 diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..835401f --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,66 @@ +# DjangoAdmin Nginx 反向代理配置 +# 部署时修改 server_name、root、proxy_pass 为实际值 + +upstream fastapi_backend { + server 127.0.0.1:8031; + keepalive 16; +} + +server { + listen 80; + server_name your-domain.com; + + charset utf-8; + client_max_body_size 50m; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + # Gzip + gzip on; + gzip_min_length 1k; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml; + gzip_vary on; + + # 前端静态资源 + root /data/apps/djangoadmin/frontend/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # API 反向代理 + location /api/ { + proxy_pass http://fastapi_backend/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # 透传上游X-Forwarded-For(不追加本机IP);无上游头时回退到直连IP + proxy_set_header X-Forwarded-For $http_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_connect_timeout 60s; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + } + + # 上传文件访问 + location /api/file/ { + alias /data/apps/djangoadmin/uploads/; + expires 7d; + access_log off; + limit_except GET { deny all; } + } + + # 禁止访问隐藏文件 + location ~ /\. { deny all; } +} diff --git a/deploy/supervisor.conf b/deploy/supervisor.conf new file mode 100644 index 0000000..b9a124b --- /dev/null +++ b/deploy/supervisor.conf @@ -0,0 +1,17 @@ +; Supervisor 进程管理示例配置 +; 将 command / directory 替换为实际路径后复制到 /etc/supervisor/conf.d/ + +[program:djangoadmin-fastapi] +; 启动命令:使用项目虚拟环境中的 uvicorn +command=/path/to/venv/bin/uvicorn src.main:app --host 127.0.0.1 --port 8031 +; 项目根目录 +directory=/path/to/DjangoAdmin_FastAPI_EleVue +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +redirect_stderr=true +stdout_logfile=/path/to/logs/supervisor.out.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=10 +environment=PYTHONUNBUFFERED=1 diff --git a/src/core/config/__init__.py b/src/core/config/__init__.py index fd0b4ab..59b0b0b 100644 --- a/src/core/config/__init__.py +++ b/src/core/config/__init__.py @@ -76,7 +76,7 @@ from core.config.app import ( FASTAPI_APP, FASTAPI_ENV, FASTAPI_DEBUG, FASTAPI_DEMO, FASTAPI_ROOT_PATH, FASTAPI_TEMPLATE_FOLDER, FASTAPI_STATIC_FOLDER, FASTAPI_UPLOAD_DIR, FASTAPI_TEMP_PATH, FASTAPI_FILE_URL, - TRUSTED_PROXY_COUNT, TRUSTED_PROXY_IPS, UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB, + UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB, ) # 数据库配置 @@ -133,7 +133,7 @@ __all__ = [ 'FASTAPI_APP', 'FASTAPI_ENV', 'FASTAPI_DEBUG', 'FASTAPI_DEMO', 'FASTAPI_ROOT_PATH', 'FASTAPI_TEMPLATE_FOLDER', 'FASTAPI_STATIC_FOLDER', 'FASTAPI_UPLOAD_DIR', 'FASTAPI_TEMP_PATH', 'FASTAPI_FILE_URL', - 'TRUSTED_PROXY_COUNT', 'TRUSTED_PROXY_IPS', 'UPLOAD_ALLOWED_EXTS', 'UPLOAD_MAX_SIZE_MB', + 'UPLOAD_ALLOWED_EXTS', 'UPLOAD_MAX_SIZE_MB', # 数据库配置 'DB_DRIVER', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', diff --git a/src/core/config/app.py b/src/core/config/app.py index f41f450..70aa8e4 100644 --- a/src/core/config/app.py +++ b/src/core/config/app.py @@ -57,7 +57,7 @@ _logger = logging.getLogger(__name__) # 应用基本信息 # ============================================================ # 应用名称 -FASTAPI_NAME = os.getenv('FASTAPI_NAME', 'FastAPI+AntdVue旗舰版') +FASTAPI_NAME = os.getenv('FASTAPI_NAME', 'FastAPI+EleVue旗舰版') # 应用版本 FASTAPI_VERSION = os.getenv('FASTAPI_VERSION', 'v3.0.0') # 应用运行地址 @@ -96,16 +96,6 @@ FASTAPI_FILE_URL = os.getenv('FASTAPI_FILE_URL', '') # CORS允许的源(逗号分隔),未配置则允许所有源但不携带凭证 CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '') -# 可信代理层数(0=不信任X-Forwarded-For,1=一层反向代理,2=两层…) -# 仅当应用部署在Nginx等反向代理后才应设置为>=1 -TRUSTED_PROXY_COUNT = int(os.getenv('TRUSTED_PROXY_COUNT', '0')) -# 可信代理IP白名单(逗号分隔,可含CIDR前的字面IP)。仅当直连对端命中此白名单且 -# TRUSTED_PROXY_COUNT>0 时才信任 X-Forwarded-For;否则 fail-closed 回退 request.client.host。 -# 防止应用被直接暴露(如 Docker bridge 映射端口)时客户端伪造 XFF 绕过限流/登录锁定 -TRUSTED_PROXY_IPS = frozenset( - ip.strip() for ip in os.getenv('TRUSTED_PROXY_IPS', '').split(',') if ip.strip() -) - # ============================================================ # 上传配置 # ============================================================ diff --git a/src/utils/ip.py b/src/utils/ip.py index 70abc54..4f1f22a 100644 --- a/src/utils/ip.py +++ b/src/utils/ip.py @@ -54,9 +54,6 @@ IP地址工具模块 (IP地理位置查询已迁移至 utils/ip2region 离线库) """ -from core.config import TRUSTED_PROXY_COUNT, TRUSTED_PROXY_IPS - - # ============================================================ # 获取客户端IP # ============================================================ @@ -64,11 +61,8 @@ def get_client_ip(request) -> str: """ 获取客户端真实IP地址 - 仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任 - X-Forwarded-For 头: - - TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头, - 直接使用 request.client.host(fail-closed,防止被直接暴露时伪造 XFF) - - 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP + 优先从 X-Forwarded-For 头取最左侧IP(即真实客户端IP), + 无代理头时回退到 request.client.host。 Args: request: FastAPI Request 对象 @@ -76,34 +70,10 @@ def get_client_ip(request) -> str: Returns: 客户端IP地址 """ - if TRUSTED_PROXY_COUNT > 0 and _peer_is_trusted_proxy(request): - forwarded = request.headers.get('X-Forwarded-For', '') - if forwarded: - ips = [ip.strip() for ip in forwarded.split(',')] - # XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧; - # 跳过最右侧 TRUSTED_PROXY_COUNT 个代理条目取真实客户端IP - index = len(ips) - TRUSTED_PROXY_COUNT - # 代理条目少于预期(如部分代理未追加 XFF)时回退到最右侧, - # 避免退回 request.client.host 拿到代理IP、导致反代部署下所有请求共享同一IP - if index < 0: - index = len(ips) - 1 - return ips[index] + forwarded = request.headers.get('X-Forwarded-For', '') + if forwarded: + # 取最左侧IP(由CDN/反代追加的真实客户端IP,右侧为代理节点IP) + return forwarded.split(',')[0].strip() client = request.client return client.host if client else '127.0.0.1' - - -# ============================================================ -# 私有辅助函数 -# ============================================================ -def _peer_is_trusted_proxy(request) -> bool: - """直连对端(TCP 层)必须是配置的可信代理IP,才允许信任 X-Forwarded-For - - 若 TRUSTED_PROXY_IPS 为空或对端不命中 → 不信任代理头。否则应用被直接暴露 - (如 Docker bridge 映射端口、未走 Nginx)时,客户端可随意伪造 XFF 首部, - 绕过基于 IP 的登录锁定/请求限流。 - """ - if not TRUSTED_PROXY_IPS: - return False - client = request.client - return client is not None and client.host in TRUSTED_PROXY_IPS diff --git a/tests/unit/utils/test_ip.py b/tests/unit/utils/test_ip.py new file mode 100644 index 0000000..f898357 --- /dev/null +++ b/tests/unit/utils/test_ip.py @@ -0,0 +1,33 @@ +"""utils/ip.py 客户端IP获取测试。""" +import types + +from utils.ip import get_client_ip + + +def _make_request(client_host, xff=None): + """构造模拟 Request 对象。""" + req = types.SimpleNamespace() + req.client = types.SimpleNamespace(host=client_host) + req.headers = {'X-Forwarded-For': xff} if xff else {} + return req + + +def test_xff_single_ip(): + """XFF 只有一个 IP → 直接返回""" + assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4' + + +def test_xff_multiple_ips_returns_leftmost(): + """XFF 多个 IP → 取最左侧(真实客户端 IP)""" + assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4' + + +def test_no_xff_falls_back_to_client_host(): + """无 XFF 头 → 回退到 request.client.host""" + assert get_client_ip(_make_request('203.0.113.9')) == '203.0.113.9' + + +def test_no_client_falls_back_to_localhost(): + """client 为 None → 回退到 127.0.0.1""" + req = types.SimpleNamespace(client=None, headers={}) + assert get_client_ip(req) == '127.0.0.1'