Limit proxy body rewriting to URLs and guard legacy certificate repair

This commit is contained in:
hellcat 2026-10-13 20:30:22 +08:00
parent c354954b6d
commit f6603a57ef
3 changed files with 72 additions and 7 deletions

View File

@ -38,6 +38,16 @@ def remove_legacy_wildcards(text):
return text return text
def update_legacy_fuc_certificate(text, directory):
for directive, filename in [('ssl_certificate', 'fullchain.pem'), ('ssl_certificate_key', 'privkey.pem')]:
old = '/etc/letsencrypt/live/tomshell.com.de/' + filename
pattern = r'(?m)^(\s*' + directive + r'\s+)' + re.escape(old) + r'(\s*;)'
text, count = re.subn(pattern, lambda m: m[1] + directory + '/' + filename + m[2], text)
if count != 1:
raise RuntimeError('Expected exactly one old fuc certificate directive; refusing ambiguous edit')
return text
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('role', choices=['gateway', 'white']) parser.add_argument('role', choices=['gateway', 'white'])
@ -45,6 +55,7 @@ def main():
parser.add_argument('--skip-legacy', action='store_true') parser.add_argument('--skip-legacy', action='store_true')
parser.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de') parser.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de')
parser.add_argument('--expected-legacy-sha256') parser.add_argument('--expected-legacy-sha256')
parser.add_argument('--expected-fuc-sha256')
parser.add_argument('--white-ip', default='104.168.30.40') parser.add_argument('--white-ip', default='104.168.30.40')
args = parser.parse_args() args = parser.parse_args()
ipaddress.ip_address(args.white_ip) ipaddress.ip_address(args.white_ip)
@ -86,9 +97,19 @@ def main():
if not args.expected_legacy_sha256 or sha(current) != args.expected_legacy_sha256: if not args.expected_legacy_sha256 or sha(current) != args.expected_legacy_sha256:
parser.error('Legacy file needs an exact current hash before modifying wildcard blocks') parser.error('Legacy file needs an exact current hash before modifying wildcard blocks')
changes[legacy_file] = remove_legacy_wildcards(current.decode()).encode() changes[legacy_file] = remove_legacy_wildcards(current.decode()).encode()
fuc_file = pathlib.Path('/etc/nginx/conf.d/fu.tomshell.my.conf')
current = fuc_file.read_bytes()
if str(fuc_file) in old_state['files']:
if sha(current) != old_state['files'][str(fuc_file)]:
parser.error('Legacy fuc file changed after previous deployment')
changes[fuc_file] = current
else:
if not args.expected_fuc_sha256 or sha(current) != args.expected_fuc_sha256:
parser.error('Legacy fuc certificate update needs an exact current file hash')
changes[fuc_file] = update_legacy_fuc_certificate(current.decode(), args.legacy_certificate).encode()
if args.stage == 'https': if args.stage == 'https':
for path, data in changes.items(): for path, data in changes.items():
if path.name == 'master.tomshell.my.conf': if path.name in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
continue continue
for cert in re.findall(rb'(?m)^\s*ssl_certificate\s+([^;]+);', data): for cert in re.findall(rb'(?m)^\s*ssl_certificate\s+([^;]+);', data):
subprocess.run(['openssl', 'x509', '-in', cert.decode(), '-checkend', '86400', '-noout'], subprocess.run(['openssl', 'x509', '-in', cert.decode(), '-checkend', '86400', '-noout'],
@ -100,7 +121,7 @@ def main():
parser.error('Legacy proxy requires root and wildcard SANs, not a single-host certificate') parser.error('Legacy proxy requires root and wildcard SANs, not a single-host certificate')
for path, data in changes.items(): for path, data in changes.items():
if path.exists() and str(path) not in old_state['files']: if path.exists() and str(path) not in old_state['files']:
if path.name != 'master.tomshell.my.conf': if path.name not in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
raise RuntimeError('Unmanaged file already exists: ' + str(path)) raise RuntimeError('Unmanaged file already exists: ' + str(path))
elif path.exists() and sha(path.read_bytes()) != old_state['files'].get(str(path)): elif path.exists() and sha(path.read_bytes()) != old_state['files'].get(str(path)):
raise RuntimeError('Managed file changed outside this deployment: ' + str(path)) raise RuntimeError('Managed file changed outside this deployment: ' + str(path))

View File

@ -58,6 +58,28 @@ def http_server(names, redirect=True):
''' '''
def body_rewrites(source, alias, key):
# Rewrite URL hosts, preserving plain brand names such as DP-LINK.NET.
rules = f''' sub_filter_once off;
sub_filter_types text/css application/javascript application/json;
sub_filter 'https://$gateway_admin_origin_host' 'https://$host';
sub_filter 'http://$gateway_admin_origin_host' 'https://$host';
sub_filter '//$gateway_admin_origin_host' '//$host';
'''
if key in ('new', 'old'):
rules += f''' sub_filter 'https://{source}' 'https://{alias}';
sub_filter 'http://{source}' 'https://{alias}';
sub_filter '//{source}' '//{alias}';
sub_filter '.{source}/' '.{alias}/';
'''
elif key == 'fuc-site':
rules += ''' sub_filter 'https://福云.com' 'https://fuc.tomtojapan.online';
sub_filter 'http://福云.com' 'https://fuc.tomtojapan.online';
sub_filter '//福云.com' '//fuc.tomtojapan.online';
'''
return rules
def https_server(names, certificate, content, cloudflare=False, white_ip=None, key_name='privatekey.pem'): def https_server(names, certificate, content, cloudflare=False, white_ip=None, key_name='privatekey.pem'):
trusted = f' include {CF_TRUST};\n' if cloudflare else '' trusted = f' include {CF_TRUST};\n' if cloudflare else ''
if white_ip: if white_ip:
@ -155,17 +177,13 @@ map "$host|$http_referer" $gateway_admin_referer {
extras = f''' proxy_redirect ~^https?://([a-z0-9-]+\\.)?{source_regex}(/.*)$ https://$1{alias}$2; extras = f''' proxy_redirect ~^https?://([a-z0-9-]+\\.)?{source_regex}(/.*)$ https://$1{alias}$2;
proxy_cookie_domain {source} {alias}; proxy_cookie_domain {source} {alias};
''' '''
elif key == 'fuc-site':
extras = " sub_filter '福云.com' 'fuc.tomtojapan.online';\n"
location = f''' resolver 1.1.1.1 1.0.0.1 valid=300s ipv6=off; location = f''' resolver 1.1.1.1 1.0.0.1 valid=300s ipv6=off;
resolver_timeout 5s; resolver_timeout 5s;
if ($gateway_admin_origin_host = '') {{ return 444; }} if ($gateway_admin_origin_host = '') {{ return 444; }}
location / {{ location / {{
{proxy_lines('$gateway_admin_origin_host', '$gateway_admin_origin_host', '$gateway_admin_origin_host', alias)}{extras} {proxy_lines('$gateway_admin_origin_host', '$gateway_admin_origin_host', '$gateway_admin_origin_host', alias)}{extras}
proxy_set_header Accept-Encoding ""; proxy_set_header Accept-Encoding "";
sub_filter_once off; {body_rewrites(source, alias, key)}
sub_filter_types text/css application/javascript application/json;
sub_filter '{source}' '{alias}';
}} }}
''' '''
data += https_server(names, cert, location, cloudflare=cf, data += https_server(names, cert, location, cloudflare=cf,

View File

@ -22,6 +22,23 @@ with tempfile.TemporaryDirectory(prefix='gateway-proxy-maps-') as folder:
sock.bind(('127.0.0.1', 0)) sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1] port = sock.getsockname()[1]
maps = render.render_white('https', '/unused')['00-gateway-admin-proxy-maps.conf'] maps = render.render_white('https', '/unused')['00-gateway-admin-proxy-maps.conf']
bodies = [
('dpl', 'dpl.tomtojapan.online', 'dp-link.net', 'dpl.tomtojapan.online', 'dpl-site', 'DP-LINK.NET'),
('fuc', 'fuc.tomtojapan.online', 'xn--9kqy16h.com', 'fuc.tomtojapan.online', 'fuc-site', '福云.com'),
('new', 'getway-dpl.tomshell.online', 'gemcook.net', 'tomshell.online', 'new', 'GEMCOOK.NET'),
('old', 'nasa.tomshell.com.de', 'diaodu.us', 'tomshell.com.de', 'old', 'DIAODU.US'),
]
locations = ''
for name, host, source, alias, key, brand in bodies:
upstream = ('getway-dpl.gemcook.net' if key == 'new' else
'nasa.diaodu.us' if key == 'old' else source)
body = f'<title>{brand}</title><a href="https://{upstream}/api?q=1">link</a><img src="//{upstream}/logo">'
locations += f''' location /body/{name} {{
default_type text/html;
{render.body_rewrites(source, alias, key)}
return 200 '{body}';
}}
'''
config = root / 'nginx.conf' config = root / 'nginx.conf'
config.write_text(f'''daemon off; config.write_text(f'''daemon off;
pid {root}/nginx.pid; pid {root}/nginx.pid;
@ -35,6 +52,7 @@ http {{
location / {{ location / {{
return 200 "$gateway_admin_origin_host|$gateway_admin_origin|$gateway_admin_referer"; return 200 "$gateway_admin_origin_host|$gateway_admin_origin|$gateway_admin_referer";
}} }}
{locations}
}} }}
}} }}
''') ''')
@ -75,6 +93,14 @@ http {{
assert actual[1] == ('https://' + upstream if own else origin), (host, origin, actual[1]) assert actual[1] == ('https://' + upstream if own else origin), (host, origin, actual[1])
assert actual[2] == ('https://' + upstream + '/example?keep=1' if own else referer) assert actual[2] == ('https://' + upstream + '/example?keep=1' if own else referer)
assertions += 3 assertions += 3
for name, host, source, alias, key, brand in bodies:
req = urllib.request.Request(f'http://127.0.0.1:{port}/body/{name}', headers={'Host': host})
with urllib.request.urlopen(req, timeout=3) as response:
actual = response.read().decode()
assert f'<title>{brand}</title>' in actual, (name, actual)
assert f'https://{host}/api?q=1' in actual, (name, actual)
assert f'//{host}/logo' in actual, (name, actual)
assertions += 3
print(f'PASS: {assertions} real-Nginx map/header checks; localhost fixture only, no origin requests') print(f'PASS: {assertions} real-Nginx map/header checks; localhost fixture only, no origin requests')
finally: finally:
process.terminate() process.terminate()