修复反向代理后客户共用限流与登录预算
This commit is contained in:
+1
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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`,营业时间与聊天时间按中国时间计算。
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user