修正后台域名与泛域名证书验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-22 07:18:43 +08:00
parent 2436894589
commit eb5758bd3b
7 changed files with 57 additions and 18 deletions

View File

@ -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

View File

@ -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')

View File

@ -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;

View File

@ -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()

View File

@ -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)

View File

@ -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)))

View File

@ -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))