Add reverse proxy masking and Cloudflare panel SSL via DNS-01.

Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-08 00:41:50 +03:00
co-authored by Cursor
commit 7ef408afe7
46 changed files with 19886 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
"""
Reverse Proxy Manager — Caddy 2 front for traffic masking.
Serves a polished decoy website on ports 80/443 and optionally reverse-proxies
a secret path to VPN backends (Xray, Telemt) on the internal Docker network.
Reduces DPI footprint: probes see a normal HTTPS site instead of a VPN endpoint.
"""
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
BACKEND_UPSTREAMS = {
'none': None,
'xray': 'amnezia-xray',
'telemt': 'telemt',
}
DEFAULT_VPN_PATH = '/cdn-cgi/challenge'
DEFAULT_BACKEND_PORT = 8443
class RevproxyManager:
PROTOCOL = 'revproxy'
CONTAINER_NAME = 'amnezia-revproxy'
REMOTE_DIR = '/opt/amnezia/revproxy'
SETTINGS_FILE = 'settings.json'
def __init__(self, ssh):
self.ssh = ssh
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command('docker --version 2>/dev/null')
return code == 0 and bool(out.strip())
def check_protocol_installed(self, protocol_type='revproxy'):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
)
return self.CONTAINER_NAME in out.strip().split('\n')
def check_container_running(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_server_status(self, protocol_type='revproxy'):
exists = self.check_protocol_installed()
running = self.check_container_running()
settings = self._read_settings() if exists else {}
site_url = self._build_site_url(settings)
return {
'container_exists': exists,
'container_running': running,
'port': 443,
'protocol': protocol_type,
'domain': settings.get('domain', ''),
'site_title': settings.get('site_title', 'CloudEdge'),
'backend': settings.get('backend', 'none'),
'backend_port': settings.get('backend_port', DEFAULT_BACKEND_PORT),
'vpn_path': settings.get('vpn_path', DEFAULT_VPN_PATH),
'tls_mode': settings.get('tls_mode', 'internal'),
'site_url': site_url,
'mask_enabled': settings.get('backend') in ('xray', 'telemt'),
}
# ===================== INSTALL / UPDATE / REMOVE =====================
def install_protocol(
self,
protocol_type='revproxy',
domain=None,
site_title=None,
backend='none',
backend_port=None,
vpn_path=None,
tls_email=None,
enable_telemt_mask=False,
):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
backend = (backend or 'none').lower()
if backend not in BACKEND_UPSTREAMS:
backend = 'none'
backend_port = int(backend_port or DEFAULT_BACKEND_PORT)
vpn_path = self._normalize_path(vpn_path or DEFAULT_VPN_PATH)
site_title = (site_title or 'CloudEdge').strip() or 'CloudEdge'
domain = (domain or '').strip().lower()
tls_email = (tls_email or '').strip()
tls_mode = 'acme' if domain else 'internal'
settings = {
'domain': domain,
'site_title': site_title,
'backend': backend,
'backend_port': backend_port,
'vpn_path': vpn_path,
'tls_email': tls_email,
'tls_mode': tls_mode,
}
log = []
if self.check_protocol_installed():
log.append('Removing previous reverse proxy container...')
self.remove_container()
log.append('Ensuring Docker network amnezia-dns-net...')
self._ensure_network()
log.append('Ensuring docker compose plugin...')
self._ensure_docker_compose()
log.append('Uploading reverse proxy files...')
self._upload_bundle(settings)
log.append('Starting Caddy reverse proxy...')
out, err, code = self.ssh.run_sudo_command(
f"sh -c 'cd {self.REMOTE_DIR} && docker compose up -d'", timeout=180
)
if code != 0:
out2, err2, code2 = self.ssh.run_sudo_command(
f"sh -c 'cd {self.REMOTE_DIR} && docker-compose up -d'", timeout=180
)
if code2 != 0:
return {
'status': 'error',
'message': f'Failed to start reverse proxy: {err or err2 or out or out2}',
}
if backend == 'telemt' and enable_telemt_mask:
log.append('Enabling Telemt site masking (mask → Caddy)...')
self._configure_telemt_mask()
elif backend == 'xray':
self.ssh.run_sudo_command(
'docker network connect amnezia-dns-net amnezia-xray 2>/dev/null || true'
)
site_url = self._build_site_url(settings)
log.append(f'Decoy site: {site_url}')
if backend != 'none':
log.append(f'VPN tunnel path: {vpn_path}{BACKEND_UPSTREAMS[backend]}:{backend_port}')
log.append('Ensure the backend listens on the internal port, not host 443.')
return {
'status': 'success',
'protocol': 'revproxy',
'port': 443,
'domain': domain,
'site_url': site_url,
'backend': backend,
'vpn_path': vpn_path,
'tls_mode': tls_mode,
'message': 'Reverse proxy installed',
'log': log,
}
def update_settings(
self,
domain=None,
site_title=None,
backend=None,
backend_port=None,
vpn_path=None,
tls_email=None,
enable_telemt_mask=False,
):
if not self.check_protocol_installed():
return {'status': 'error', 'message': 'Reverse proxy not installed'}
current = self._read_settings()
settings = {
'domain': (domain if domain is not None else current.get('domain', '')).strip().lower(),
'site_title': (site_title if site_title is not None else current.get('site_title', 'CloudEdge')).strip(),
'backend': (backend if backend is not None else current.get('backend', 'none')).lower(),
'backend_port': int(backend_port if backend_port is not None else current.get('backend_port', DEFAULT_BACKEND_PORT)),
'vpn_path': self._normalize_path(vpn_path if vpn_path is not None else current.get('vpn_path', DEFAULT_VPN_PATH)),
'tls_email': (tls_email if tls_email is not None else current.get('tls_email', '')).strip(),
'tls_mode': 'acme' if (domain if domain is not None else current.get('domain')) else 'internal',
}
if settings['backend'] not in BACKEND_UPSTREAMS:
settings['backend'] = 'none'
self._write_settings(settings)
self._write_caddyfile(settings)
self._write_site_html(settings)
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
if settings['backend'] == 'telemt' and enable_telemt_mask:
self._configure_telemt_mask()
return {
'status': 'success',
**settings,
'site_url': self._build_site_url(settings),
}
def save_server_config(self, protocol_type, config_content):
"""Save raw Caddyfile and hot-reload."""
path = f"{self.REMOTE_DIR}/Caddyfile"
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), path)
self.ssh.run_sudo_command(
f"docker exec {self.CONTAINER_NAME} caddy reload --config /etc/caddy/Caddyfile 2>/dev/null "
f"|| docker restart {self.CONTAINER_NAME}"
)
def remove_container(self, protocol_type='revproxy'):
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"rm -rf {self.REMOTE_DIR}")
return True
def get_settings(self):
return self._read_settings()
def get_caddyfile(self):
out, _, code = self.ssh.run_sudo_command(f"cat {self.REMOTE_DIR}/Caddyfile 2>/dev/null")
if code != 0:
return ''
return out
# ===================== INTERNALS =====================
def _ensure_network(self):
script = """
docker network inspect amnezia-dns-net >/dev/null 2>&1 || \
docker network create --subnet=172.29.172.0/24 amnezia-dns-net
"""
self.ssh.run_sudo_script(script)
def _ensure_docker_compose(self):
out, _, code = self.ssh.run_command('docker compose version 2>/dev/null')
if code == 0 and out.strip():
return
self.ssh.run_sudo_command(
'apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null || true',
timeout=120,
)
def _upload_bundle(self, settings):
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy')
remote = self.REMOTE_DIR
self.ssh.run_sudo_command(f'mkdir -p {remote}/site')
self.ssh.run_sudo_command(f'chmod -R 755 {remote}')
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{remote}/docker-compose.yml')
self._write_settings(settings)
self._write_caddyfile(settings)
self._write_site_html(settings)
for fname in ('index.html', 'style.css'):
local_path = os.path.join(local_dir, 'site', fname)
if os.path.exists(local_path) and fname != 'index.html':
with open(local_path, 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{remote}/site/{fname}')
def _write_settings(self, settings):
content = json.dumps(settings, indent=2, ensure_ascii=False)
self.ssh.upload_file_sudo(content, f'{self.REMOTE_DIR}/{self.SETTINGS_FILE}')
def _read_settings(self):
out, _, code = self.ssh.run_sudo_command(
f"cat {self.REMOTE_DIR}/{self.SETTINGS_FILE} 2>/dev/null"
)
if code != 0 or not out.strip():
return {}
try:
return json.loads(out)
except json.JSONDecodeError:
return {}
def _write_site_html(self, settings):
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy', 'site')
with open(os.path.join(local_dir, 'index.html'), 'r', encoding='utf-8') as f:
html = f.read()
title = settings.get('site_title', 'CloudEdge')
domain = settings.get('domain') or self.ssh.host
html = html.replace('{{SITE_TITLE}}', self._escape_html(title))
html = html.replace('{{SITE_DOMAIN}}', self._escape_html(domain))
self.ssh.upload_file_sudo(html, f'{self.REMOTE_DIR}/site/index.html')
style_path = os.path.join(local_dir, 'style.css')
if os.path.exists(style_path):
with open(style_path, 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{self.REMOTE_DIR}/site/style.css')
def _write_caddyfile(self, settings):
caddyfile = self._build_caddyfile(settings)
self.ssh.upload_file_sudo(caddyfile, f'{self.REMOTE_DIR}/Caddyfile')
def _build_caddyfile(self, settings):
domain = settings.get('domain', '')
site_title = settings.get('site_title', 'CloudEdge')
backend = settings.get('backend', 'none')
backend_port = int(settings.get('backend_port', DEFAULT_BACKEND_PORT))
vpn_path = self._normalize_path(settings.get('vpn_path', DEFAULT_VPN_PATH))
tls_email = settings.get('tls_email', '')
site_address = domain if domain else ':443'
global_block = '{\n\tadmin off\n\tservers {\n\t\tprotocols h1 h2 h3\n\t}\n'
if tls_email and domain:
global_block += f'\temail {tls_email}\n'
global_block += '}\n\n'
tls_line = ''
if not domain:
tls_line = '\n\ttls internal'
backend_block = ''
if backend != 'none' and BACKEND_UPSTREAMS.get(backend):
upstream = f'{BACKEND_UPSTREAMS[backend]}:{backend_port}'
backend_block = f"""
\t@vpn path {vpn_path}*
\thandle @vpn {{
\t\treverse_proxy {upstream} {{
\t\t\theader_up Host {{host}}
\t\t\theader_up X-Real-IP {{remote_host}}
\t\t\theader_up X-Forwarded-For {{remote_host}}
\t\t\theader_up X-Forwarded-Proto {{scheme}}
\t\t\ttransport http {{
\t\t\t\tkeepalive 30s
\t\t\t\tkeepalive_idle_conns 100
\t\t\t}}
\t\t}}
\t}}
"""
return f"""{global_block}{site_address} {{{tls_line}
\troot * /srv/site
\tencode gzip zstd
\theader {{
\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload"
\t\tX-Content-Type-Options nosniff
\t\tX-Frame-Options SAMEORIGIN
\t\tReferrer-Policy strict-origin-when-cross-origin
\t\t-Server
\t}}
{backend_block}
\thandle {{
\t\ttry_files {{path}} {{path}}/ /index.html
\t\tfile_server
\t}}
}}
:80 {{
\tredir https://{{host}}{{uri}} permanent
}}
"""
def _configure_telemt_mask(self):
"""Point Telemt mask at the Caddy container on the Docker network."""
config_path = '/opt/amnezia/telemt/config.toml'
out, _, code = self.ssh.run_sudo_command(f"test -f {config_path} && cat {config_path}")
if code != 0:
return
config = out
config = re.sub(r'mask\s*=\s*(true|false)', 'mask = true', config)
if 'mask_host' in config:
config = re.sub(r'#?\s*mask_host\s*=\s*".*?"', 'mask_host = "amnezia-revproxy"', config)
else:
config = config.replace('[censorship]', '[censorship]\nmask_host = "amnezia-revproxy"')
if 'mask_port' in config:
config = re.sub(r'#?\s*mask_port\s*=\s*\d+', 'mask_port = 443', config)
else:
config = config.replace('mask_host = "amnezia-revproxy"', 'mask_host = "amnezia-revproxy"\nmask_port = 443')
self.ssh.upload_file_sudo(config.replace('\r\n', '\n'), config_path)
self.ssh.run_sudo_command(
'docker network connect amnezia-dns-net telemt 2>/dev/null || true'
)
self.ssh.run_sudo_command(
'docker kill -s HUP telemt 2>/dev/null || docker restart telemt 2>/dev/null || true'
)
def _build_site_url(self, settings):
domain = settings.get('domain', '')
if domain:
return f'https://{domain}'
return f'https://{self.ssh.host}'
@staticmethod
def _normalize_path(path):
path = (path or DEFAULT_VPN_PATH).strip()
if not path.startswith('/'):
path = '/' + path
return path.rstrip('/') or '/'
@staticmethod
def _escape_html(text):
return (
str(text)
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
)