Smail/test/tools/runtime_proxy.py
smail 9c450f1734
Some checks are pending
Security Checks / security (push) Waiting to run
运行时配置清理阶段检查点
2026-10-05 01:31:34 +08:00

147 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'''复用部署模板,在独立网络中验收 Nginx 与 8001/8002,不访问线上实例。'''
import http.client
import shutil
import time
HOST = 'mail.runtime.invalid'
def prepare_nginx(target, root, template):
directory = target / 'nginx'
directory.mkdir(mode=0o750)
shutil.chown(directory, user='smail', group='smail')
source = template.read_text(encoding='utf-8')
assert 'proxy_pass https://127.0.0.1:8002;' in source
assert 'proxy_ssl_verify on;' in source
assert 'proxy_set_header X-Forwarded-For $remote_addr;' in source
site = source.replace('example.com', 'runtime.invalid')
site = site.replace('/opt/smail/ssl/custom.crt', str(root / 'ssl/runtime.crt'))
site = site.replace('/opt/smail/ssl/custom.key', str(root / 'ssl/runtime.key'))
site = site.replace('/etc/ssl/certs/ca-certificates.crt', str(root / 'ssl/runtime.crt'))
site = site.replace('/var/lib/smail-acme', str(directory / 'acme'))
(directory / 'site.conf').write_text(site, encoding='utf-8')
config = '\n'.join([
'worker_processes 1;',
'pid ' + str(directory / 'nginx.pid') + ';',
'error_log ' + str(directory / 'error.log') + ' warn;',
'events { worker_connections 128; }',
'http {',
' access_log off;',
*(' ' + name + '_temp_path ' + str(directory / name) + ';'
for name in ('client_body', 'proxy', 'fastcgi', 'uwsgi', 'scgi')),
' include ' + str(directory / 'site.conf') + ';',
'}',
'',
])
(directory / 'nginx.conf').write_text(config, encoding='utf-8')
for name in ('site.conf', 'nginx.conf'):
path = directory / name
path.chmod(0o640)
shutil.chown(path, user='root', group='smail')
def configure_proxy(client, context, login, client_type, connection_type):
status = client.success('/api/v1/config/status', method='GET')
assert status['available']
payload = client.success('/api/v1/config/base', method='GET')
assert payload['http']['port'] == 8001 and payload['http']['httpsPort'] == 8002
payload.pop('databasePasswordStatus', None)
payload['http']['publicHttpsPort'] = 443
payload['http']['trustedProxyCidrs'] = ['127.0.0.1/32']
request = {'expectedGeneration': status['activeGeneration'], 'payload': payload,
'confirmation': {'confirmed': True, 'method': 'recent_auth'}}
client.success('/api/v1/config/base/validate', request)
candidate = client.success('/api/v1/config/base', request, 'PUT')
client.success('/api/v1/config/base/apply', {
'generation': candidate['generation'], 'candidateId': candidate['candidateId'],
})
for _ in range(30):
try:
active = client.success('/api/v1/config/base', method='GET')
assert active['http']['publicHttpsPort'] == 443
assert active['http']['trustedProxyCidrs'] == ['127.0.0.1/32']
break
except (OSError, http.client.HTTPException, AssertionError):
time.sleep(1)
else:
raise AssertionError('反代配置未通过正式接口生效')
assert active['http']['port'] == 8001 and active['http']['httpsPort'] == 8002
assert client_type(context, port=8002).success('/api/v1/login', login)['account'] == login['account']
redirects = {}
for port in (80, 8001):
connection = http.client.HTTPConnection('127.0.0.1', port, timeout=10)
try:
connection.request('GET', '/login?review=port', headers={'Host': HOST})
response = connection.getresponse()
response.read()
assert response.status in (301, 302, 307, 308), (port, response.status)
location = response.getheader('Location')
assert location == 'https://' + HOST + '/login?review=port', (port, location)
redirects[str(port)] = location
finally:
connection.close()
recovery = verify_recovery_page(context, connection_type)
return {'configuration_api': True, 'internal_http': 8001, 'internal_https': 8002,
'public_https': 443, 'upstream_tls_verified': True, 'redirects': redirects,
'recovery': recovery}
def verify_recovery_page(context, connection_type):
from html.parser import HTMLParser
class PageParser(HTMLParser):
def __init__(self):
super().__init__()
self.login_url = None
self.scripts = []
def handle_starttag(self, tag, attrs):
attributes = dict(attrs)
if tag == 'div' and attributes.get('id') == 'app':
self.login_url = attributes.get('data-login-url')
if tag == 'script' and attributes.get('src'):
self.scripts.append(attributes['src'])
def fetch(path):
connection = connection_type(HOST, 443, timeout=10, context=context)
try:
connection.request('GET', path)
response = connection.getresponse()
content = response.read()
return response.status, dict(response.getheaders()), content
finally:
connection.close()
status, headers, _ = fetch('/public-password-recovery')
assert status == 302 and headers.get('Location') == 'https://' + HOST + '/rest/'
status, headers, content = fetch('/rest/')
assert status == 200 and headers.get('Cache-Control') == 'no-store'
page = PageParser()
page.feed(content.decode('utf-8'))
assert page.login_url == 'https://' + HOST + '/#/login'
assert page.scripts, '恢复页面缺少生产构建入口'
for script in page.scripts:
assert script.startswith('/assets/') and '..' not in script
status, headers, content = fetch(script)
assert status == 200 and content and 'javascript' in headers.get('Content-Type', '')
return {'entry_redirect': True, 'login_url': page.login_url,
'no_store': True, 'scripts_loaded': len(page.scripts)}
def verify_proxy_sources(context, login, client_type):
first = client_type(context, source='127.0.0.2')
second = client_type(context, source='127.0.0.3')
wrong = {**login, 'password': 'invalid-proxy-review-password'}
for _ in range(10):
status, data, _ = first.request('/api/v1/login', wrong)
assert status == 200 and data is not None and data['errorNo'] != 0
status, data, _ = first.request('/api/v1/login', login, extra_headers={
'X-Forwarded-For': '127.0.0.3', 'X-Real-IP': '127.0.0.3',
})
assert status == 200 and data is not None and data['errorNo'] != 0, '伪造来源绕过登录限流'
assert second.success('/api/v1/login', login)['account'] == login['account'], '不同来源被错误合并限流'
return {'blocked_source': '127.0.0.2', 'unaffected_source': '127.0.0.3',
'forged_headers_rejected': True, 'independent_login': True}