dpl/scripts/gateway-admin-proxy/render.py

237 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Render the user-approved admin hostnames and bounded white-machine proxies."""
import argparse
import ipaddress
import os
import pathlib
import re
CA = '/etc/ssl/certs/ca-certificates.crt'
ACME = '/var/lib/gateway-admin-proxy/acme'
CF_TRUST = '/etc/nginx/white-proxy-cloudflare-realip.conf'
HEALTH_MAP = '''log_format gateway_admin_proxy_health '$time_iso8601 host=$host status=$status upstream_status=$upstream_status duration=$request_time';
map $status $gateway_admin_proxy_log_failure {
~^[45] 1;
default 0;
}
'''
def private_logs():
# Nginx error lines include the entire request URI, including subscription tokens.
# Keep critical diagnostics plus status-only failure records for these new vhosts.
return ''' access_log /var/log/nginx/gateway-admin-proxy.health.log gateway_admin_proxy_health if=$gateway_admin_proxy_log_failure;
error_log /var/log/nginx/gateway-admin-proxy.error.log crit;
'''
def proxy_lines(upstream, tls_name, host, alias=None):
result = f''' proxy_pass https://{upstream}$request_uri;
proxy_ssl_server_name on;
proxy_ssl_name {tls_name};
proxy_ssl_verify on;
proxy_ssl_verify_depth 4;
proxy_ssl_trusted_certificate {CA};
proxy_http_version 1.1;
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-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $gateway_admin_proxy_connection;
proxy_connect_timeout 15s;
proxy_read_timeout 120s;
proxy_cache_bypass 1;
proxy_no_cache 1;
'''
if alias:
result += f''' proxy_set_header X-Forwarded-Host $host;
proxy_set_header Origin $gateway_admin_origin;
proxy_set_header Referer $gateway_admin_referer;
proxy_redirect https://{host}/ https://$host/;
proxy_cookie_domain {host} $host;
'''
else:
result += ' proxy_set_header X-Forwarded-Host $host;\n'
return result
def http_server(names, redirect=True):
fallback = 'return 301 https://$host$request_uri;' if redirect else 'return 404;'
return f'''server {{
listen 80;
listen [::]:80;
server_name {names};
{private_logs()}
location ^~ /.well-known/acme-challenge/ {{
root {ACME};
try_files $uri =404;
}}
location / {{ {fallback} }}
}}
'''
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'):
trusted = f' include {CF_TRUST};\n' if cloudflare else ''
if white_ip:
trusted += f''' set_real_ip_from {white_ip};
real_ip_header X-Forwarded-For;
real_ip_recursive on;
'''
return f'''server {{
listen 443 ssl;
listen [::]:443 ssl;
server_name {names};
ssl_certificate {certificate}/fullchain.pem;
ssl_certificate_key {certificate}/{key_name};
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 20m;
{private_logs()}
{trusted}{content}
}}
'''
def render_gateway(stage, legacy_path, white_ip):
files = {'00-gateway-admin-proxy-maps.conf': HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection {
default upgrade;
'' close;
}
'''}
for kind, port, tls in [('fuc', 64444, 'fu101.gembox-sub.com'),
('dpl', 16444, 'c2.deeplink-subscribe.com')]:
name = f'getway-{kind}.gemcook.net'
content = http_server(name, stage == 'https')
if stage == 'https':
if kind == 'fuc':
location = f''' location = / {{ return 302 {legacy_path}; }}
location ^~ {legacy_path} {{
{proxy_lines('127.0.0.1:'+str(port), tls, name)} }}
location / {{ return 404; }}
'''
else:
location = f''' location / {{
{proxy_lines('127.0.0.1:'+str(port), tls, name)} }}
'''
# The admin upstreams retain their own certificates and authentication.
location += ' add_header Cache-Control "private, no-store" always;\n'
content += https_server(name, '/etc/gateway-admin-letsencrypt/live/gateway-admin',
location, white_ip=white_ip, key_name='privkey.pem')
files[f'gateway-admin-{kind}.conf'] = content
return files
def render_white(stage, legacy_certificate, skip_legacy=False):
files = {}
maps = HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection {
default upgrade;
'' close;
}
map $host $gateway_admin_origin_host {
tomshell.online gemcook.net;
~^(?<gnew>[a-z0-9-]+)\\.tomshell\\.online$ $gnew.gemcook.net;
tomshell.com.de diaodu.us;
~^(?<gold>[a-z0-9-]+)\\.tomshell\\.com\\.de$ $gold.diaodu.us;
weiwei.tomtojapan.online hw.lshan.eu.org;
dpl.tomtojapan.online dp-link.net;
fuc.tomtojapan.online xn--9kqy16h.com;
default '';
}
map "$host|$http_origin" $gateway_admin_origin {
"~^(?<grequest_origin>[a-z0-9.-]+)\\|https://\\k<grequest_origin>$" https://$gateway_admin_origin_host;
default $http_origin;
}
map "$host|$http_referer" $gateway_admin_referer {
"~^(?<grequest_referer>[a-z0-9.-]+)\\|https://\\k<grequest_referer>(?<grefpath>/.*)$" https://$gateway_admin_origin_host$grefpath;
default $http_referer;
}
'''
files['00-gateway-admin-proxy-maps.conf'] = maps
specs = [
('new', 'tomshell.online *.tomshell.online', '/etc/white-proxy-origin/tomshell.online',
'gemcook.net', 'tomshell.online', True),
('old', 'tomshell.com.de *.tomshell.com.de', legacy_certificate,
'diaodu.us', 'tomshell.com.de', False),
('weiwei', 'weiwei.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online',
'hw.lshan.eu.org', 'weiwei.tomtojapan.online', True),
('dpl-site', 'dpl.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online',
'dp-link.net', 'dpl.tomtojapan.online', True),
('fuc-site', 'fuc.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online',
'xn--9kqy16h.com', 'fuc.tomtojapan.online', True),
]
for key, names, cert, source, alias, cf in specs:
if key == 'old' and skip_legacy:
continue
data = http_server(names, stage == 'https')
if stage == 'https':
extras = ''
if key in ('new', 'old'):
source_regex = re.escape(source)
extras = f''' proxy_redirect ~^https?://([a-z0-9-]+\\.)?{source_regex}(/.*)$ https://$1{alias}$2;
proxy_cookie_domain {source} {alias};
'''
location = f''' resolver 1.1.1.1 1.0.0.1 valid=300s ipv6=off;
resolver_timeout 5s;
if ($gateway_admin_origin_host = '') {{ return 444; }}
location / {{
{proxy_lines('$gateway_admin_origin_host', '$gateway_admin_origin_host', '$gateway_admin_origin_host', alias)}{extras}
proxy_set_header Accept-Encoding "";
{body_rewrites(source, alias, key)}
}}
'''
if key == 'weiwei':
location += ' add_header Cache-Control "private, no-store" always;\n'
data += https_server(names, cert, location, cloudflare=cf,
key_name='privatekey.pem' if cf else 'privkey.pem')
files[f'white-{key}-proxy.conf'] = data
return files
def main():
p = argparse.ArgumentParser()
p.add_argument('role', choices=['gateway', 'white'])
p.add_argument('--stage', choices=['http', 'https'], required=True)
p.add_argument('--output', type=pathlib.Path, required=True)
p.add_argument('--white-ip', default='104.168.30.40')
p.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de')
p.add_argument('--skip-legacy', action='store_true', help='Leave legacy proxy untouched until its wildcard certificate is ready')
a = p.parse_args()
ipaddress.ip_address(a.white_ip)
if not re.fullmatch(r'/[A-Za-z0-9/._-]+', a.legacy_certificate):
p.error('Invalid certificate directory')
legacy_path = os.environ.get('LEGACY_ADMIN_PATH', '')
if a.role == 'gateway' and a.stage == 'https' and not re.fullmatch(r'/[A-Za-z0-9_-]+/', legacy_path):
p.error('Supply the existing legacy admin prefix through LEGACY_ADMIN_PATH')
files = render_gateway(a.stage, legacy_path, a.white_ip) if a.role == 'gateway' else render_white(a.stage, a.legacy_certificate, a.skip_legacy)
a.output.mkdir(parents=True, exist_ok=True)
for name, content in files.items():
(a.output / name).write_text(content)
print(f'Rendered {len(files)} {a.role}/{a.stage} files; no service configuration changed')
if __name__ == '__main__':
main()