diff --git a/deploy/nginx/deploy_certificate.py b/deploy/nginx/deploy_certificate.py index 83a7b55..c335901 100644 --- a/deploy/nginx/deploy_certificate.py +++ b/deploy/nginx/deploy_certificate.py @@ -17,7 +17,10 @@ import time import uuid -DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn') +DOMAINS = ('solit.cn', '*.solit.cn') +VERIFY_HOSTNAMES = ('solit.cn', 'web.solit.cn', 'mail.solit.cn', 'smtp.solit.cn', + 'imap.solit.cn', 'pop.solit.cn') +CERTIFICATE_SNI = 'web.solit.cn' LINEAGE = Path('/var/lib/smail-acme-tls/certificates') TARGET = Path('/opt/smail/ssl') BACKUPS = Path('/var/backups') @@ -96,7 +99,13 @@ def replace_file(path, data, uid, gid): def validate(directory): - for domain in DOMAINS: + names = run('openssl', 'x509', '-in', str(directory / 'candidate.crt'), '-noout', + '-ext', 'subjectAltName').decode('utf-8') + certificate_domains = {item.strip()[4:] for line in names.splitlines() + for item in line.split(',') if item.strip().startswith('DNS:')} + if not set(DOMAINS).issubset(certificate_domains): + raise RuntimeError('证书必须同时覆盖根域名和泛域名') + for domain in VERIFY_HOSTNAMES: run('openssl', 'verify', '-CAfile', CA_FILE, '-purpose', 'sslserver', '-untrusted', str(directory / 'candidate.issuer.crt'), '-verify_hostname', domain, str(directory / 'candidate.crt')) @@ -119,7 +128,7 @@ def verify_live_certificate(fingerprint): while True: try: with socket.create_connection(('127.0.0.1', port), timeout=2) as connection: - with context.wrap_socket(connection, server_hostname='mail.solit.cn') as secure: + with context.wrap_socket(connection, server_hostname=CERTIFICATE_SNI) as secure: actual = hashlib.sha256(secure.getpeercert(binary_form=True)).hexdigest() if actual == fingerprint: break diff --git a/deploy/nginx/renew_certificate.py b/deploy/nginx/renew_certificate.py index 8d05444..f036560 100644 --- a/deploy/nginx/renew_certificate.py +++ b/deploy/nginx/renew_certificate.py @@ -11,7 +11,7 @@ import deploy_certificate as deploy ROOT = Path('/var/lib/smail-acme-tls') -DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn') +DOMAINS = deploy.DOMAINS RENEW_TIMEOUT = 600 WEBROOT = Path('/var/lib/smail-acme') diff --git a/deploy/nginx/smail.conf b/deploy/nginx/smail.conf index ec1deb9..3af7164 100644 --- a/deploy/nginx/smail.conf +++ b/deploy/nginx/smail.conf @@ -8,7 +8,7 @@ server { server { listen 80; listen [::]:80; - server_name solit.cn mail.solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn; + server_name solit.cn mail.solit.cn web.solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn; location ^~ /.well-known/acme-challenge/ { root /var/lib/smail-acme; @@ -17,7 +17,7 @@ server { } location / { - return 308 https://mail.solit.cn$request_uri; + return 308 https://web.solit.cn$request_uri; } } @@ -31,16 +31,16 @@ server { server { listen 443 ssl; listen [::]:443 ssl; - server_name solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn; + server_name solit.cn mail.solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn; ssl_certificate /opt/smail/ssl/custom.crt; ssl_certificate_key /opt/smail/ssl/custom.key; - return 308 https://mail.solit.cn$request_uri; + return 308 https://web.solit.cn$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; - server_name mail.solit.cn; + server_name web.solit.cn; ssl_certificate /opt/smail/ssl/custom.crt; ssl_certificate_key /opt/smail/ssl/custom.key; @@ -50,10 +50,11 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header Connection ""; proxy_ssl_server_name on; - proxy_ssl_name mail.solit.cn; + proxy_ssl_name web.solit.cn; proxy_ssl_verify on; proxy_ssl_verify_depth 3; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; diff --git a/test/deployment/test_certificate_renewal.py b/test/deployment/test_certificate_renewal.py index 76d9823..6162af4 100644 --- a/test/deployment/test_certificate_renewal.py +++ b/test/deployment/test_certificate_renewal.py @@ -264,7 +264,7 @@ class DeploymentTests(unittest.TestCase): def test_wrong_domain_does_not_change_live_files(self): with patch.object(deploy, 'DOMAINS', ('wrong.example',)), \ - self.assertRaises(subprocess.CalledProcessError): + self.assertRaisesRegex(RuntimeError, '根域名和泛域名'): deploy.main() self.assert_old_untouched() diff --git a/test/deployment/test_domain_acceptance.py b/test/deployment/test_domain_acceptance.py new file mode 100644 index 0000000..69657d9 --- /dev/null +++ b/test/deployment/test_domain_acceptance.py @@ -0,0 +1,28 @@ +"""验证 Smail 的后台域名、别名跳转和根域名泛域名证书约束。""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +NGINX = (ROOT / 'deploy/nginx/smail.conf').read_text(encoding='utf-8') +CERTIFICATE = (ROOT / 'deploy/nginx/deploy_certificate.py').read_text(encoding='utf-8') +RENEWAL = (ROOT / 'deploy/nginx/renew_certificate.py').read_text(encoding='utf-8') + + +class DomainAcceptanceTest(unittest.TestCase): + def test_web_domain_is_the_only_public_web_proxy(self): + self.assertIn('server_name web.solit.cn;', NGINX) + self.assertIn('proxy_ssl_name web.solit.cn;', NGINX) + self.assertIn('return 308 https://web.solit.cn$request_uri;', NGINX) + self.assertNotIn('return 308 https://mail.solit.cn$request_uri;', NGINX) + + def test_certificate_requests_root_and_wildcard(self): + self.assertIn("DOMAINS = ('solit.cn', '*.solit.cn')", CERTIFICATE) + self.assertIn("CERTIFICATE_SNI = 'web.solit.cn'", CERTIFICATE) + self.assertIn("'web.solit.cn'", CERTIFICATE) + self.assertIn('DOMAINS = deploy.DOMAINS', RENEWAL) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/test/deployment/test_nginx_redirect_acceptance.py b/test/deployment/test_nginx_redirect_acceptance.py index ba4715d..7517790 100644 --- a/test/deployment/test_nginx_redirect_acceptance.py +++ b/test/deployment/test_nginx_redirect_acceptance.py @@ -11,7 +11,7 @@ import verify_nginx_tls as verify class RedirectAcceptanceTest(unittest.TestCase): - expected = 'https://mail.solit.cn/acceptance?x=1' + expected = 'https://web.solit.cn/acceptance?x=1' def check(self, first, second): results = {'checks': []} @@ -29,12 +29,12 @@ class RedirectAcceptanceTest(unittest.TestCase): self.assertEqual(self.check(self.response(), self.response()), []) def test_internal_https_port_is_failure(self): - failures = self.check(self.response(), self.response('https://mail.solit.cn:8002/acceptance?x=1')) + failures = self.check(self.response(), self.response('https://web.solit.cn:8002/acceptance?x=1')) self.assertEqual(len(failures), 1) self.assertEqual(failures[0]['port'], 8001) def test_public_port_does_not_skip_validation(self): - failures = self.check(self.response('http://mail.solit.cn/acceptance?x=1'), self.response()) + failures = self.check(self.response('http://web.solit.cn/acceptance?x=1'), self.response()) self.assertEqual([item['port'] for item in failures], [80]) def test_missing_location_records_failure(self): @@ -46,7 +46,7 @@ class RedirectAcceptanceTest(unittest.TestCase): self.assertEqual(failures[0]['actualStatus'], 200) def test_path_and_query_must_be_preserved(self): - for location in ('https://mail.solit.cn/', 'https://mail.solit.cn/acceptance', + for location in ('https://web.solit.cn/', 'https://web.solit.cn/acceptance', 'https://other.invalid/acceptance?x=1'): with self.subTest(location=location): self.assertTrue(self.check(self.response(), self.response(location))) diff --git a/test/deployment/verify_nginx_tls.py b/test/deployment/verify_nginx_tls.py index f743768..1e2feab 100644 --- a/test/deployment/verify_nginx_tls.py +++ b/test/deployment/verify_nginx_tls.py @@ -11,7 +11,7 @@ import ssl import subprocess -HOST = 'mail.solit.cn' +HOST = 'web.solit.cn' CONTEXT = ssl.create_default_context() @@ -47,7 +47,8 @@ def main(): leaf = subprocess.run(['openssl', 'x509', '-in', '/opt/smail/ssl/custom.crt', '-outform', 'DER'], check=True, capture_output=True).stdout expected = hashlib.sha256(leaf).hexdigest() - for port, host in [(443, HOST), (8002, HOST), (465, 'smtp.solit.cn'), + for port, host in [(443, HOST), (443, 'solit.cn'), (443, 'mail.solit.cn'), + (8002, HOST), (465, 'smtp.solit.cn'), (993, 'imap.solit.cn'), (995, 'pop.solit.cn')]: with socket.create_connection(('127.0.0.1', port), timeout=10) as raw: with CONTEXT.wrap_socket(raw, server_hostname=host) as stream: @@ -86,7 +87,7 @@ def main(): raise AssertionError('unknown SNI accepted') root = json.loads(Path('/opt/smail/config/config.json').read_text()) assert root['http']['port'] == 8001 and root['http']['httpsPort'] == 8002 - assert root['security']['webAuthnRpOrigins'] == ['https://mail.solit.cn'] + assert root['security']['webAuthnRpOrigins'] == ['https://web.solit.cn'] results['fingerprintSHA256'] = expected results['status'] = 'failed' if results['failures'] else 'passed' print(json.dumps(results, ensure_ascii=False, indent=2))