Add isolated gateway admin and white proxy provisioning
This commit is contained in:
commit
668571772b
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
149
deploy.py
Normal file
149
deploy.py
Normal 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()
|
||||||
5
gateway-admin-cert.service
Normal file
5
gateway-admin-cert.service
Normal 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
8
gateway-admin-cert.timer
Normal 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
34
install-origin-cert.py
Normal 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
17
issue-gateway-admin.sh
Normal 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
17
issue-white-legacy.sh
Normal 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
116
legacy-dns-hook.py
Normal 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
200
render.py
Normal 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
9
renew-gateway-admin.sh
Normal 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
9
renew-white-legacy.sh
Normal 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
45
sync-cf-ips.py
Normal 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
85
verify-maps.py
Normal 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
5
white-proxy-cert.service
Normal 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
8
white-proxy-cert.timer
Normal 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
|
||||||
5
white-proxy-cloudflare-ips.service
Normal file
5
white-proxy-cloudflare-ips.service
Normal 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
|
||||||
8
white-proxy-cloudflare-ips.timer
Normal file
8
white-proxy-cloudflare-ips.timer
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user