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:
@@ -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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
)
|
||||
Reference in New Issue
Block a user