Add isolated gateway admin and white proxy provisioning

This commit is contained in:
hellcat 2026-10-13 19:53:10 +08:00
commit 668571772b
17 changed files with 722 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

149
deploy.py Normal file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Install only approved proxy files, backing up and reverting on Nginx failure."""
import argparse
import datetime
import hashlib
import importlib.util
import ipaddress
import json
import os
import pathlib
import re
import shutil
import subprocess
spec = importlib.util.spec_from_file_location('render', pathlib.Path(__file__).with_name('render.py'))
render = importlib.util.module_from_spec(spec)
spec.loader.exec_module(render)
def sha(data):
return hashlib.sha256(data).hexdigest()
def remove_legacy_wildcards(text):
spans = []
for m in re.finditer(r'(?m)^server\s*\{', text):
end, depth = m.end(), 1
while end < len(text) and depth:
depth += (text[end] == '{') - (text[end] == '}')
end += 1
block = text[m.start():end]
if re.search(r'(?m)^\s*server_name\s+\*\.tomshell\.com\.de\s*;', block):
spans.append((m.start(), end))
if len(spans) != 2:
raise RuntimeError('Expected exactly two old wildcard server blocks; refusing ambiguous edit')
for start, end in reversed(spans):
text = text[:start] + text[end:]
return text
def main():
parser = argparse.ArgumentParser()
parser.add_argument('role', choices=['gateway', 'white'])
parser.add_argument('--stage', choices=['http', 'https'], required=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('--expected-legacy-sha256')
parser.add_argument('--white-ip', default='104.168.30.40')
args = parser.parse_args()
ipaddress.ip_address(args.white_ip)
if not re.fullmatch(r'/[A-Za-z0-9/._-]+', args.legacy_certificate):
parser.error('Invalid certificate directory')
if os.geteuid() != 0:
parser.error('Remote deployment requires root')
os.umask(0o077)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
state_path = pathlib.Path('/var/lib/gateway-admin-proxy/state.json')
state_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
old_state = json.loads(state_path.read_text()) if state_path.exists() else {'files': {}}
if old_state.get('role', args.role) != args.role:
parser.error('Deployment role differs from existing state')
if args.role == 'gateway':
legacy = ''
if args.stage == 'https':
data = json.loads(subprocess.check_output(['docker', 'inspect', 'subscribe-admin'], text=True))[0]
values = dict(x.split('=', 1) for x in data['Config']['Env'] if '=' in x)
legacy = '/' + values['ADMIN_SECRET_PATH'].strip('/') + '/'
if not re.fullmatch(r'/[A-Za-z0-9_-]+/', legacy):
parser.error('Invalid existing admin prefix')
if 'location ^~ ' + legacy not in pathlib.Path('/etc/nginx/conf.d/g1.gembox-sub.com.conf').read_text():
parser.error('Legacy admin prefix disagrees with current vhost')
files = render.render_gateway(args.stage, legacy, args.white_ip)
else:
files = render.render_white(args.stage, args.legacy_certificate, args.skip_legacy)
changes = {pathlib.Path('/etc/nginx/conf.d') / name: data.encode() for name, data in files.items()}
if args.role == 'white' and args.stage == 'https' and not args.skip_legacy:
legacy_file = pathlib.Path('/etc/nginx/conf.d/master.tomshell.my.conf')
current = legacy_file.read_bytes()
if str(legacy_file) in old_state['files']:
if sha(current) != old_state['files'][str(legacy_file)]:
parser.error('Legacy file changed after previous deployment')
changes[legacy_file] = current
else:
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')
changes[legacy_file] = remove_legacy_wildcards(current.decode()).encode()
if args.stage == 'https':
for path, data in changes.items():
if path.name == 'master.tomshell.my.conf':
continue
for cert in re.findall(rb'(?m)^\s*ssl_certificate\s+([^;]+);', data):
subprocess.run(['openssl', 'x509', '-in', cert.decode(), '-checkend', '86400', '-noout'],
check=True, capture_output=True)
if path.name == 'white-old-proxy.conf':
san = subprocess.check_output(['openssl', 'x509', '-in', cert.decode(), '-noout',
'-ext', 'subjectAltName'], text=True)
if 'DNS:*.tomshell.com.de' not in san or 'DNS:tomshell.com.de' not in san:
parser.error('Legacy proxy requires root and wildcard SANs, not a single-host certificate')
for path, data in changes.items():
if path.exists() and str(path) not in old_state['files']:
if path.name != 'master.tomshell.my.conf':
raise RuntimeError('Unmanaged file already exists: ' + 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))
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
backup = pathlib.Path('/root/codex-backups/gateway-admin-proxy') / (timestamp + '-' + args.role)
backup.mkdir(parents=True, mode=0o700)
originals = {}
modes = {}
for path in changes:
originals[str(path)] = path.read_bytes() if path.exists() else None
modes[str(path)] = path.stat().st_mode & 0o777 if path.exists() else 0o644
if path.exists():
shutil.copy2(path, backup / path.name)
manifest = {'role': args.role, 'stage': args.stage,
'before': {p: sha(data) if data is not None else None for p, data in originals.items()},
'after': {str(p): sha(data) for p, data in changes.items()}, 'modes': modes}
(backup / 'manifest.json').write_text(json.dumps(manifest, indent=2))
try:
for path, data in changes.items():
temp = path.with_suffix('.conf.new')
temp.write_bytes(data)
temp.chmod(modes[str(path)])
os.replace(temp, path)
pathlib.Path(render.ACME).mkdir(parents=True, mode=0o755, exist_ok=True)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
except Exception:
for path, data in originals.items():
target = pathlib.Path(path)
if data is None:
target.unlink(missing_ok=True)
else:
target.write_bytes(data)
target.chmod(modes[path])
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
raise
old_state.update({'role': args.role, 'stage': args.stage, 'backup': str(backup)})
old_state['files'].update(manifest['after'])
state_new = state_path.with_suffix('.json.new')
state_new.write_text(json.dumps(old_state, indent=2))
os.replace(state_new, state_path)
print(json.dumps({'role': args.role, 'stage': args.stage, 'backup': str(backup),
'installed': [str(x) for x in changes], 'nginx_valid': True, 'reloaded': True}))
if __name__ == '__main__':
main()

View File

@ -0,0 +1,5 @@
[Unit]
Description=Renew only the two gateway administration host certificates
[Service]
Type=oneshot
ExecStart=/bin/sh /opt/gateway-admin-proxy/provision/renew-gateway-admin.sh

8
gateway-admin-cert.timer Normal file
View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily isolated gateway admin certificate renewal
[Timer]
OnCalendar=*-*-* 03:40:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target

34
install-origin-cert.py Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Install the public CF certificate only after matching the server-held key and SANs."""
import argparse
import os
import pathlib
import subprocess
p = argparse.ArgumentParser()
p.add_argument('domain', choices=['tomshell.online', 'tomtojapan.online'])
p.add_argument('certificate', type=pathlib.Path)
a = p.parse_args()
os.umask(0o077)
root = pathlib.Path('/etc/white-proxy-origin') / a.domain
key = root / 'privatekey.pem'
if not key.exists():
p.error('Generate the key and CSR on the white machine first')
subprocess.run(['openssl', 'x509', '-in', str(a.certificate), '-checkend', '86400', '-noout'],
check=True, capture_output=True)
san = subprocess.check_output(['openssl', 'x509', '-in', str(a.certificate), '-noout', '-ext',
'subjectAltName'], text=True)
if 'DNS:*.' + a.domain not in san or 'DNS:' + a.domain not in san:
p.error('Certificate does not contain the expected root and wildcard names')
cert_pub = subprocess.check_output(['openssl', 'x509', '-in', str(a.certificate), '-noout', '-pubkey'])
key_pub = subprocess.check_output(['openssl', 'pkey', '-in', str(key), '-pubout'])
if cert_pub.strip() != key_pub.strip():
p.error('Certificate public key differs from the server-held private key')
target = root / 'fullchain.pem'
if target.exists() and target.read_bytes() != a.certificate.read_bytes():
p.error('Different certificate already exists; take an explicit backup before replacement')
temporary = root / 'fullchain.pem.new'
temporary.write_bytes(a.certificate.read_bytes())
temporary.chmod(0o600)
os.replace(temporary, target)
print('Installed matching public origin certificate: ' + a.domain + '; private key retained on server')

17
issue-gateway-admin.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# Reuse the already accepted gateway LE account without running global hooks.
set -eu
umask 077
test "$(id -u)" = 0
test -d /etc/dpl-letsencrypt/accounts/acme-v02.api.letsencrypt.org
install -d -m 700 /etc/gateway-admin-letsencrypt /var/lib/gateway-admin-proxy/certbot /var/log/gateway-admin-certbot
if [ ! -d /etc/gateway-admin-letsencrypt/accounts ]; then
cp -a /etc/dpl-letsencrypt/accounts /etc/gateway-admin-letsencrypt/accounts
fi
exec certbot certonly --non-interactive --webroot \
--webroot-path /var/lib/gateway-admin-proxy/acme \
--config-dir /etc/gateway-admin-letsencrypt \
--work-dir /var/lib/gateway-admin-proxy/certbot \
--logs-dir /var/log/gateway-admin-certbot \
--no-directory-hooks --cert-name gateway-admin \
-d getway-fuc.gemcook.net -d getway-dpl.gemcook.net

17
issue-white-legacy.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
umask 077
test "$(id -u)" = 0
test -d /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org
install -d -m 700 /etc/white-proxy-letsencrypt /var/lib/white-proxy-certbot /var/log/white-proxy-certbot
if [ ! -d /etc/white-proxy-letsencrypt/accounts ]; then
cp -a /etc/letsencrypt/accounts /etc/white-proxy-letsencrypt/accounts
fi
exec certbot certonly --non-interactive --manual --preferred-challenges dns \
--manual-auth-hook 'python3 /opt/gateway-admin-proxy/provision/legacy-dns-hook.py auth' \
--manual-cleanup-hook 'python3 /opt/gateway-admin-proxy/provision/legacy-dns-hook.py cleanup' \
--config-dir /etc/white-proxy-letsencrypt \
--work-dir /var/lib/white-proxy-certbot \
--logs-dir /var/log/white-proxy-certbot \
--no-directory-hooks --cert-name tomshell.com.de \
-d tomshell.com.de -d '*.tomshell.com.de'

116
legacy-dns-hook.py Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Certbot DNS hook: only one approved delegation, exact TXT IDs, IPv4 API."""
import hashlib
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import time
import urllib.parse
CONFIG = pathlib.Path('/etc/white-proxy-dns/config.json')
TOKEN = pathlib.Path('/etc/white-proxy-dns/cf-token')
STATE = pathlib.Path('/var/lib/white-proxy-dns')
DOMAIN = 'tomshell.com.de'
TARGET = 'old-com-de.acme.tomshell.online'
AUTH_NS = ('jaziel.ns.cloudflare.com', 'nena.ns.cloudflare.com')
def check_private(path):
if path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
raise RuntimeError('DNS credentials must be root-owned and private')
def api(method, suffix, payload=None):
check_private(TOKEN)
check_private(CONFIG)
cfg = json.loads(CONFIG.read_text())
zone = cfg['zone_id']
if not re.fullmatch(r'[a-f0-9]{32}', zone) or cfg['target'] != TARGET:
raise RuntimeError('Unapproved DNS configuration')
token = TOKEN.read_text().strip()
if not re.fullmatch(r'[A-Za-z0-9_-]{40}', token):
raise RuntimeError('Invalid token format')
with tempfile.TemporaryDirectory(prefix='acme-api-', dir=STATE) as folder:
body = pathlib.Path(folder) / 'request.json'
args = ['curl', '-4', '--silent', '--show-error', '--fail-with-body',
'--connect-timeout', '10', '--max-time', '30', '--config', '-',
'--request', method,
'https://api.cloudflare.com/client/v4/zones/' + zone + '/dns_records' + suffix]
if payload is not None:
body.write_text(json.dumps(payload))
args.extend(['--data-binary', '@' + str(body)])
config = 'header = "Authorization: Bearer ' + token + '"\nheader = "Content-Type: application/json"\n'
result = subprocess.run(args, input=config, capture_output=True, text=True)
if result.returncode:
raise RuntimeError('Cloudflare DNS API request failed; response intentionally not logged')
value = json.loads(result.stdout)
if not value.get('success'):
raise RuntimeError('Cloudflare DNS API rejected request')
return value['result']
def dns(name, kind, server):
return subprocess.check_output(['dig', '-4', '+time=3', '+tries=1', '+short',
'@' + server, name, kind], text=True).strip()
def matches(record, validation):
return record.get('type') == 'TXT' and record.get('name') == TARGET and record.get('content') == validation
def main():
if os.geteuid() != 0 or len(sys.argv) != 2 or sys.argv[1] not in ('auth', 'cleanup'):
raise RuntimeError('Root auth/cleanup mode required')
os.umask(0o077)
STATE.mkdir(parents=True, mode=0o700, exist_ok=True)
domain = os.environ['CERTBOT_DOMAIN'].removeprefix('*.')
validation = os.environ['CERTBOT_VALIDATION']
if domain != DOMAIN or not re.fullmatch(r'[A-Za-z0-9_-]{43}', validation):
raise RuntimeError('Challenge outside approved domain')
state = STATE / (hashlib.sha256(validation.encode()).hexdigest() + '.json')
if sys.argv[1] == 'cleanup':
if not state.exists():
return
record_id = json.loads(state.read_text())['id']
if not re.fullmatch(r'[a-f0-9]{32}', record_id):
raise RuntimeError('Invalid TXT record ID')
record = api('GET', '/' + record_id)
if not matches(record, validation):
raise RuntimeError('TXT record changed; refusing deletion')
api('DELETE', '/' + record_id)
state.unlink()
return
for ns in ('ns1.dns-parking.com', 'ns2.dns-parking.com'):
if dns('_acme-challenge.' + DOMAIN, 'CNAME', ns).rstrip('.') != TARGET:
raise RuntimeError('Approved CNAME delegation not published')
if state.exists():
raise RuntimeError('Existing challenge state; inspect before retry')
record = api('POST', '', {'type': 'TXT', 'name': TARGET, 'content': validation, 'ttl': 60,
'comment': 'Isolated legacy white-proxy ACME challenge'})
state.write_text(json.dumps({'id': record['id']}))
try:
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
if all('"' + validation + '"' in dns(TARGET, 'TXT', ns).splitlines() for ns in AUTH_NS):
# Also allow recursors to replace a cached negative answer before validation.
for _ in range(24):
if all('"' + validation + '"' in dns('_acme-challenge.' + DOMAIN, 'TXT', ns).splitlines()
for ns in ('1.1.1.1', '8.8.8.8')):
return
time.sleep(5)
raise RuntimeError('Challenge not visible through public DNS recursors')
time.sleep(5)
raise RuntimeError('Challenge propagation timed out')
except Exception:
if matches(api('GET', '/' + record['id']), validation):
api('DELETE', '/' + record['id'])
state.unlink()
raise
if __name__ == '__main__':
main()

200
render.py Normal file
View File

@ -0,0 +1,200 @@
#!/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'
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};
access_log off;
location ^~ /.well-known/acme-challenge/ {{
root {ACME};
try_files $uri =404;
}}
location / {{ {fallback} }}
}}
'''
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;
access_log off;
error_log /var/log/nginx/gateway-admin-proxy.error.log warn;
{trusted}{content}
}}
'''
def render_gateway(stage, legacy_path, white_ip):
files = {'00-gateway-admin-proxy-maps.conf': '''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 = '''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;
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),
('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};
'''
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;
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 "";
sub_filter_once off;
sub_filter_types text/css application/javascript application/json;
sub_filter '{source}' '{alias}';
}}
'''
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()

9
renew-gateway-admin.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/sh
set -eu
umask 077
exec certbot renew --quiet \
--config-dir /etc/gateway-admin-letsencrypt \
--work-dir /var/lib/gateway-admin-proxy/certbot \
--logs-dir /var/log/gateway-admin-certbot \
--no-directory-hooks \
--deploy-hook '/usr/sbin/nginx -t && /bin/systemctl reload nginx'

9
renew-white-legacy.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/sh
set -eu
umask 077
exec certbot renew --quiet \
--config-dir /etc/white-proxy-letsencrypt \
--work-dir /var/lib/white-proxy-certbot \
--logs-dir /var/log/white-proxy-certbot \
--no-directory-hooks \
--deploy-hook '/usr/sbin/nginx -t && /bin/systemctl reload nginx'

45
sync-cf-ips.py Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Synchronize only the new white proxy vhosts' Cloudflare trust include."""
import ipaddress
import os
import pathlib
import subprocess
target = pathlib.Path('/etc/nginx/white-proxy-cloudflare-realip.conf')
ranges = []
for version in (4, 6):
p = subprocess.run(['curl', '-4', '--fail', '--silent', '--show-error', '--max-time', '30',
f'https://www.cloudflare.com/ips-v{version}'],
check=True, capture_output=True, text=True)
nets = [ipaddress.ip_network(x.strip(), strict=True) for x in p.stdout.splitlines() if x.strip()]
if not ((5 if version == 4 else 2) <= len(nets) <= 100):
raise RuntimeError('Unexpected Cloudflare range count')
if any(n.version != version or n.prefixlen < (8 if version == 4 else 16) for n in nets):
raise RuntimeError('Invalid or overbroad Cloudflare trust range')
ranges.extend(str(n) for n in nets)
if len(ranges) != len(set(ranges)):
raise RuntimeError('Duplicate Cloudflare ranges')
data = ('# Managed new white proxy vhosts only; Cloudflare official ranges.\n' +
''.join(f'set_real_ip_from {n};\n' for n in ranges) +
'real_ip_header CF-Connecting-IP;\nreal_ip_recursive off;\n').encode()
old = target.read_bytes() if target.exists() else None
if old == data:
print('White proxy Cloudflare trust unchanged')
raise SystemExit(0)
temporary = target.with_suffix('.conf.new')
temporary.write_bytes(data)
temporary.chmod(0o644)
os.replace(temporary, target)
try:
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
except Exception:
if old is None:
target.unlink()
else:
temporary.write_bytes(old)
os.replace(temporary, target)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
raise
print(f'White proxy Cloudflare trust synchronized: {len(ranges)} ranges')

85
verify-maps.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Exercise real Nginx mapping and Origin/Referer isolation using localhost only."""
import importlib.util
import pathlib
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
spec = importlib.util.spec_from_file_location('render', pathlib.Path(__file__).with_name('render.py'))
render = importlib.util.module_from_spec(spec)
spec.loader.exec_module(render)
nginx = shutil.which('nginx')
if not nginx:
raise SystemExit('Nginx is required for this isolated integration check')
with tempfile.TemporaryDirectory(prefix='gateway-proxy-maps-') as folder:
root = pathlib.Path(folder)
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
maps = render.render_white('https', '/unused')['00-gateway-admin-proxy-maps.conf']
config = root / 'nginx.conf'
config.write_text(f'''daemon off;
pid {root}/nginx.pid;
error_log {root}/error.log;
events {{ worker_connections 64; }}
http {{
access_log off;
{maps}
server {{
listen 127.0.0.1:{port};
location / {{
return 200 "$gateway_admin_origin_host|$gateway_admin_origin|$gateway_admin_referer";
}}
}}
}}
''')
subprocess.run([nginx, '-t', '-c', str(config), '-p', folder], check=True,
capture_output=True, text=True)
process = subprocess.Popen([nginx, '-c', str(config), '-p', folder],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
try:
for _ in range(40):
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
if process.poll() is not None:
raise RuntimeError(process.stderr.read())
time.sleep(.05)
cases = [
('tomshell.online', 'gemcook.net'),
('getway-fuc.tomshell.online', 'getway-fuc.gemcook.net'),
('getway-dpl.tomshell.online', 'getway-dpl.gemcook.net'),
('nasa.tomshell.com.de', 'nasa.diaodu.us'),
('tomshell.com.de', 'diaodu.us'),
('dpl.tomtojapan.online', 'dp-link.net'),
('fuc.tomtojapan.online', 'xn--9kqy16h.com'),
('not-registered.example', ''),
('two.levels.tomshell.online', ''),
]
assertions = 0
for host, upstream in cases:
for origin in ['https://' + host, 'https://other.tomshell.online', 'https://outside.example']:
referer = origin + '/example?keep=1'
req = urllib.request.Request('http://127.0.0.1:' + str(port) + '/',
headers={'Host': host, 'Origin': origin, 'Referer': referer})
with urllib.request.urlopen(req, timeout=3) as response:
actual = response.read().decode().split('|')
assert actual[0] == upstream, (host, actual[0], upstream)
own = origin == 'https://' + host
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)
assertions += 3
print(f'PASS: {assertions} real-Nginx map/header checks; localhost fixture only, no origin requests')
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()

5
white-proxy-cert.service Normal file
View File

@ -0,0 +1,5 @@
[Unit]
Description=Renew isolated public wildcard certificate for legacy white proxy
[Service]
Type=oneshot
ExecStart=/bin/sh /opt/gateway-admin-proxy/provision/renew-white-legacy.sh

8
white-proxy-cert.timer Normal file
View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily renewal check for legacy white proxy certificate
[Timer]
OnCalendar=*-*-* 04:10:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,5 @@
[Unit]
Description=Synchronize CF ranges for the new white proxy vhosts
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/gateway-admin-proxy/provision/sync-cf-ips.py

View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily white proxy CF trust refresh
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target