From 80bb27515ca730b6c989be0b4e72996025a890ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 18:41:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=8D=E5=90=91=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=AE=A2=E6=88=B7=E5=85=B1=E7=94=A8=E9=99=90?= =?UTF-8?q?=E6=B5=81=E4=B8=8E=E7=99=BB=E5=BD=95=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- deploy/baiyue-logistics-chat.service | 2 +- docs/deployment.md | 4 +- tests/test_deployment.py | 80 ++++++++++++++++++++++++++++ tests/test_gray_config.py | 4 ++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index fffb906..7f6ddc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ USER 10001:10001 EXPOSE 5000 HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"] -CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--call", "deploy.wsgi:create_app"] +CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--trusted-proxy=*", "--trusted-proxy-count=1", "--trusted-proxy-headers=x-forwarded-for", "--call", "deploy.wsgi:create_app"] FROM runtime AS test COPY deploy/ ./deploy/ diff --git a/deploy/baiyue-logistics-chat.service b/deploy/baiyue-logistics-chat.service index 3906221..de14e80 100644 --- a/deploy/baiyue-logistics-chat.service +++ b/deploy/baiyue-logistics-chat.service @@ -10,7 +10,7 @@ Group=baiyue WorkingDirectory=/opt/baiyue-logistics-chat Environment=PYTHONUNBUFFERED=1 Environment=PYTHONDONTWRITEBYTECODE=1 -ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app +ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 --trusted-proxy-headers=x-forwarded-for --call deploy.wsgi:create_app Restart=on-failure RestartSec=3 TimeoutStopSec=90 diff --git a/docs/deployment.md b/docs/deployment.md index 66279b9..fb25b00 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,7 +2,9 @@ 现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。 -应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。 +应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录独立验证账号及订单范围,匿名查单不会读取订单数据。 + +限流和登录失败次数按客户端 IP 计算。Nginx 必须用 `$remote_addr` 覆盖 `X-Forwarded-For`;Waitress 只信任这一项代理头和最后一跳地址,不接受代理头里的 Host、协议或更早的地址。容器镜像在仅由本机 Nginx 接入、宿主端口保持回环绑定的前提下使用 `--trusted-proxy=*`,以兼容 Docker 网关地址;systemd 入口只信任 `127.0.0.1`。直接运行 `am.py` 不接受客户端自报的代理 IP。代理规则参见 [Waitress 文档](https://docs.pylonsproject.org/projects/waitress/en/stable/reverse-proxy.html)。 容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。 diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 5224fb2..bb03572 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -1,14 +1,19 @@ """Exercise the server factory using disposable state/log paths and no model key.""" import io +import json import logging import os from pathlib import Path +import shlex import tempfile import unittest from unittest.mock import patch from deploy import wsgi +from logistics_chat import create_app +from waitress.adjustments import Adjustments +from waitress.proxy_headers import proxy_headers_middleware class DeploymentTests(unittest.TestCase): @@ -44,5 +49,80 @@ class DeploymentTests(unittest.TestCase): self.assertEqual(app.config['HOST'], '127.0.0.1') +class ProxyIdentityTests(unittest.TestCase): + def application(self, runtime): + root = Path(__file__).resolve().parents[1] + if runtime == 'docker': + command = next(line[4:] for line in (root/'Dockerfile').read_text(encoding='utf-8').splitlines() + if line.startswith('CMD ')) + args = json.loads(command)[3:] + else: + command = next(line[len('ExecStart='):] for line in + (root/'deploy/baiyue-logistics-chat.service').read_text(encoding='utf-8').splitlines() + if line.startswith('ExecStart=')) + args = shlex.split(command)[3:] + options, _ = Adjustments.parse_args(args) + options.pop('call'); options.pop('help') + settings = Adjustments(**options) + folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup) + data = Path(folder.name) + app = create_app({'TESTING':True,'DATABASE':data/'history.db','UPLOAD_DIR':data/'uploads', + 'BACKUP_DIR':data/'backups','LOG_DIR':data/'logs','RATE_LIMIT_PER_MIN':1000}) + app.config['PROXY_TEST_PEER'] = '172.18.0.1' if runtime == 'docker' else '127.0.0.1' + app.wsgi_app = proxy_headers_middleware(app.wsgi_app, trusted_proxy=settings.trusted_proxy, + trusted_proxy_count=settings.trusted_proxy_count, trusted_proxy_headers=settings.trusted_proxy_headers) + return app + + def clients(self, app): + clients = [] + for address in ('198.51.100.10', '198.51.100.20'): + client = app.test_client() + client.environ_base['REMOTE_ADDR'] = app.config['PROXY_TEST_PEER'] + client.environ_base['HTTP_X_FORWARDED_FOR'] = address + client.post('/api/visitor/session', json={'key':str(len(clients)+1)*64}) + clients.append(client) + app.extensions['rate_hits'].clear() + return clients + + def test_proxy_clients_do_not_share_request_rate_limits(self): + for runtime in ('docker', 'systemd'): + with self.subTest(runtime=runtime): + app = self.application(runtime) + a, b = self.clients(app) + app.config['RATE_LIMIT_PER_MIN'] = 2 + self.assertEqual(a.get('/api/get_history').status_code, 200) + self.assertEqual(a.get('/api/get_history').status_code, 200) + self.assertEqual(a.get('/api/get_history').status_code, 429) + self.assertEqual(b.get('/api/get_history').status_code, 200) + + def test_proxy_clients_do_not_share_failed_login_budgets(self): + for runtime in ('docker', 'systemd'): + with self.subTest(runtime=runtime): + app = self.application(runtime) + a, b = self.clients(app) + for _ in range(5): + self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code,403) + self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'123456'}).status_code,429) + self.assertTrue(b.post('/api/customer/login',json={'username':'admin','password':'123456'}).json.get('authenticated')) + + def test_only_the_last_forwarded_address_is_used_and_other_proxy_claims_are_ignored(self): + from flask import request + for runtime in ('docker', 'systemd'): + with self.subTest(runtime=runtime): + app = self.application(runtime) + @app.get('/proxy-probe') + def probe(): + return {'ip':request.remote_addr,'host':request.host,'scheme':request.scheme} + response = app.test_client().get('/proxy-probe',base_url='https://chat.example.com', + environ_overrides={'REMOTE_ADDR':app.config['PROXY_TEST_PEER']},headers={ + 'X-Forwarded-For':'203.0.113.99, 198.51.100.10', + 'X-Forwarded-Host':'evil.example','X-Forwarded-Proto':'http'}) + self.assertEqual(response.json,{'ip':'198.51.100.10','host':'chat.example.com','scheme':'https'}) + if runtime == 'systemd': + untrusted = app.test_client().get('/proxy-probe',environ_overrides={'REMOTE_ADDR':'203.0.113.1'}, + headers={'X-Forwarded-For':'198.51.100.10'}) + self.assertEqual(untrusted.json['ip'],'203.0.113.1') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_gray_config.py b/tests/test_gray_config.py index 99b92bc..ffc9582 100644 --- a/tests/test_gray_config.py +++ b/tests/test_gray_config.py @@ -66,6 +66,10 @@ class GrayConfigTests(unittest.TestCase): pattern, body = match.groups() self.assertIn('auth_basic off;', body) self.assertIn('auth_basic "Baiyue gray acceptance";', config) + self.assertIn('proxy_set_header X-Forwarded-For $remote_addr;',config) + self.assertNotIn('$proxy_add_x_forwarded_for',config) + compose=(Path(__file__).resolve().parents[1]/'deploy/compose.gray.yml').read_text(encoding='utf-8') + self.assertIn('"127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"',compose) for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages', '/api/customer/session','/api/customer/login','/api/customer/logout', '/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',